@praxisjs/di 1.3.13 → 1.3.15

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,247 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- import { Container, Token, token, type Constructor } from "../container";
4
-
5
- describe("Token", () => {
6
- it("stores description", () => {
7
- const t = new Token("MY_SERVICE");
8
- expect(t.description).toBe("MY_SERVICE");
9
- });
10
-
11
- it("toString includes description", () => {
12
- const t = new Token("FOO");
13
- expect(t.toString()).toBe("Token(FOO)");
14
- });
15
-
16
- it("token() factory creates a Token", () => {
17
- const t = token<string>("DB_URL");
18
- expect(t).toBeInstanceOf(Token);
19
- expect(t.description).toBe("DB_URL");
20
- });
21
- });
22
-
23
- describe("Container", () => {
24
- it("registers and resolves a singleton service", () => {
25
- class MyService {}
26
- const c = new Container();
27
- c.register(MyService);
28
- const a = c.resolve(MyService);
29
- const b = c.resolve(MyService);
30
- expect(a).toBeInstanceOf(MyService);
31
- expect(a).toBe(b); // singleton
32
- });
33
-
34
- it("creates new instance for transient scope", () => {
35
- class Counter {
36
- value = Math.random();
37
- }
38
- const c = new Container();
39
- c.register(Counter, { scope: "transient" });
40
- const a = c.resolve(Counter);
41
- const b = c.resolve(Counter);
42
- expect(a).not.toBe(b);
43
- });
44
-
45
- it("throws when resolving unregistered service", () => {
46
- class Unknown {}
47
- const c = new Container();
48
- expect(() => c.resolve(Unknown)).toThrow("[DI] Service not registered");
49
- });
50
-
51
- it("registerValue stores a value by token", () => {
52
- const DB_URL = token<string>("DB_URL");
53
- const c = new Container();
54
- c.registerValue(DB_URL, "postgres://localhost/db");
55
- expect(c.resolve(DB_URL)).toBe("postgres://localhost/db");
56
- });
57
-
58
- it("registerFactory computes value via factory", () => {
59
- const MAX = token<number>("MAX");
60
- const c = new Container();
61
- c.registerFactory(MAX, () => 100);
62
- expect(c.resolve(MAX)).toBe(100);
63
- });
64
-
65
- it("throws for unregistered token", () => {
66
- const t = token<string>("MISSING");
67
- const c = new Container();
68
- expect(() => c.resolve(t)).toThrow("[DI] Token not registered");
69
- });
70
-
71
- it("child container resolves from parent", () => {
72
- const URL_TOKEN = token<string>("URL");
73
- const parent = new Container();
74
- parent.registerValue(URL_TOKEN, "https://example.com");
75
- const child = parent.createChild();
76
- expect(child.resolve(URL_TOKEN)).toBe("https://example.com");
77
- });
78
-
79
- it("child container can override parent registration", () => {
80
- const URL_TOKEN = token<string>("URL");
81
- const parent = new Container();
82
- parent.registerValue(URL_TOKEN, "https://parent.com");
83
- const child = parent.createChild();
84
- child.registerValue(URL_TOKEN, "https://child.com");
85
- expect(child.resolve(URL_TOKEN)).toBe("https://child.com");
86
- expect(parent.resolve(URL_TOKEN)).toBe("https://parent.com");
87
- });
88
-
89
- it("register() is chainable", () => {
90
- class A {}
91
- class B {}
92
- const c = new Container();
93
- expect(c.register(A).register(B)).toBe(c);
94
- });
95
-
96
- it("parent singleton is reused when resolved from child container", () => {
97
- class SharedService {}
98
- const parent = new Container();
99
- parent.register(SharedService, { scope: "singleton" });
100
- const child = parent.createChild();
101
-
102
- const fromChild = child.resolve(SharedService);
103
- const fromParent = parent.resolve(SharedService);
104
- expect(fromChild).toBe(fromParent);
105
- });
106
-
107
- it("child container falls back to parent for unregistered service", () => {
108
- class MyDep {}
109
- const parent = new Container();
110
- parent.register(MyDep);
111
- const child = parent.createChild();
112
- expect(child.resolve(MyDep)).toBeInstanceOf(MyDep);
113
- });
114
-
115
- it("grandchild resolves from grandparent (three-level chain)", () => {
116
- const URL_TOKEN = token<string>("GRANDPARENT_URL");
117
- const grandparent = new Container();
118
- grandparent.registerValue(URL_TOKEN, "https://grandparent.com");
119
- const child = grandparent.createChild();
120
- const grandchild = child.createChild();
121
- expect(grandchild.resolve(URL_TOKEN)).toBe("https://grandparent.com");
122
- });
123
-
124
- it("registerFactory can resolve other services from the container", () => {
125
- class Logger {}
126
- const LOG_WRAP = token<{ logger: Logger }>("LOG_WRAP");
127
- const c = new Container();
128
- c.register(Logger);
129
- c.registerFactory(LOG_WRAP, (container) => ({
130
- logger: container.resolve(Logger),
131
- }));
132
- const result = c.resolve(LOG_WRAP);
133
- expect(result.logger).toBeInstanceOf(Logger);
134
- });
135
-
136
- it("registering the same class twice replaces the previous registration", () => {
137
- class Counter {
138
- value = Math.random();
139
- }
140
- const c = new Container();
141
- c.register(Counter, { scope: "singleton" });
142
- const first = c.resolve(Counter);
143
-
144
- // Re-register resets the descriptor (no cached instance)
145
- c.register(Counter, { scope: "singleton" });
146
- const second = c.resolve(Counter);
147
-
148
- // After re-registration, a new singleton is created
149
- expect(second).toBeInstanceOf(Counter);
150
- expect(second).not.toBe(first);
151
- });
152
-
153
- it("re-registering with different scope switches scope correctly", () => {
154
- class Svc {}
155
- const c = new Container();
156
- c.register(Svc, { scope: "singleton" });
157
- c.resolve(Svc); // warm up singleton
158
-
159
- c.register(Svc, { scope: "transient" });
160
- const a = c.resolve(Svc);
161
- const b = c.resolve(Svc);
162
- expect(a).toBeInstanceOf(Svc);
163
- expect(a).not.toBe(b);
164
- });
165
-
166
- it("factory function that throws — error message includes which service failed", () => {
167
- class Broken {
168
- constructor() {
169
- throw new Error("boom");
170
- }
171
- }
172
- const c = new Container();
173
- c.register(Broken);
174
- expect(() => c.resolve(Broken)).toThrow("Broken");
175
- expect(() => c.resolve(Broken)).toThrow("[DI] Failed to instantiate");
176
- });
177
-
178
- it("resolve() called concurrently on the same uninitialized singleton — returns same instance or does not crash", async () => {
179
- class SlowService {}
180
- const c = new Container();
181
- c.register(SlowService, { scope: "singleton" });
182
-
183
- const [a, b] = await Promise.all([
184
- Promise.resolve(c.resolve(SlowService)),
185
- Promise.resolve(c.resolve(SlowService)),
186
- ]);
187
-
188
- expect(a).toBeInstanceOf(SlowService);
189
- expect(a).toBe(b);
190
- });
191
-
192
- it("re-registering a service clears the previous singleton instance", () => {
193
- class Svc {
194
- id = Math.random();
195
- }
196
- const c = new Container();
197
- c.register(Svc, { scope: "singleton" });
198
- const first = c.resolve(Svc);
199
-
200
- c.register(Svc, { scope: "singleton" });
201
- const second = c.resolve(Svc);
202
-
203
- expect(second).not.toBe(first);
204
- });
205
-
206
- it("circular dependency chain including a Token shows the Token name in the error", () => {
207
- const MY_TOKEN = token<unknown>("MyToken");
208
- const c = new Container();
209
- c.registerValue(MY_TOKEN, "value");
210
-
211
- // Pre-populate the stack as if we are already in the middle of resolving MY_TOKEN
212
- const stack = new Set<Constructor | Token<unknown>>([MY_TOKEN]);
213
- expect(() => c.resolve(MY_TOKEN, stack)).toThrow("Token(MyToken)");
214
- });
215
-
216
- it("circular dependency chain with a Constructor shows the class name (k.name branch)", () => {
217
- class ServiceA {}
218
- const c = new Container();
219
- c.register(ServiceA);
220
-
221
- // Pre-populate the stack as if ServiceA is already being resolved
222
- const stack = new Set<Constructor | Token<unknown>>([ServiceA]);
223
- expect(() => c.resolve(ServiceA as never, stack)).toThrow("ServiceA");
224
- });
225
-
226
- it("instantiate rethrows DI errors thrown inside a constructor without wrapping", () => {
227
- const c = new Container();
228
- class Unregistered {}
229
- class Outer {
230
- constructor() {
231
- // Manually triggers a [DI] error inside the constructor body
232
- c.resolve(Unregistered);
233
- }
234
- }
235
- c.register(Outer);
236
-
237
- const err = (() => {
238
- try {
239
- c.resolve(Outer);
240
- } catch (e) {
241
- return e as Error;
242
- }
243
- })()!;
244
- expect(err.message).toMatch(/\[DI\] Service not registered/);
245
- expect(err.message).not.toMatch(/Failed to instantiate/);
246
- });
247
- });
@@ -1,343 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
-
3
- import { Container, container, token } from "../container";
4
- import { Injectable, Inject, InjectContainer, Scope, inject } from "../decorators";
5
-
6
- function makeFieldCtx(name: string) {
7
- const initializers: Array<(this: unknown) => void> = [];
8
- return {
9
- ctx: {
10
- name,
11
- kind: "field" as const,
12
- addInitializer(fn: (this: unknown) => void) {
13
- initializers.push(fn);
14
- },
15
- } as ClassFieldDecoratorContext,
16
- run(instance: unknown) {
17
- initializers.forEach((fn) => {
18
- fn.call(instance);
19
- });
20
- },
21
- };
22
- }
23
-
24
- // ── @Injectable ───────────────────────────────────────────────────────────────
25
-
26
- describe("Injectable", () => {
27
- it("with no arguments — defaults to singleton scope", () => {
28
- @Injectable()
29
- class SingletonByDefault {}
30
- const a = container.resolve(SingletonByDefault);
31
- const b = container.resolve(SingletonByDefault);
32
- expect(a).toBe(b);
33
- });
34
-
35
- it("registers the class in the global container", () => {
36
- @Injectable()
37
- class MyService {
38
- greet() {
39
- return "hello";
40
- }
41
- }
42
- expect(container.resolve(MyService)).toBeInstanceOf(MyService);
43
- expect(container.resolve(MyService).greet()).toBe("hello");
44
- });
45
-
46
- it("registers with transient scope — each resolve returns a new instance", () => {
47
- @Injectable({ scope: "transient" })
48
- class TmpService {}
49
- expect(container.resolve(TmpService)).not.toBe(
50
- container.resolve(TmpService),
51
- );
52
- });
53
- });
54
-
55
- // ── @Inject ───────────────────────────────────────────────────────────────────
56
-
57
- describe("Inject", () => {
58
- it("injects a registered service via property", () => {
59
- @Injectable()
60
- class Logger {
61
- log(msg: string) {
62
- return msg;
63
- }
64
- }
65
-
66
- const { ctx, run } = makeFieldCtx("logger");
67
- Inject(Logger)(undefined, ctx);
68
- const instance: Record<string, unknown> = {};
69
- run(instance);
70
- expect(instance.logger).toBeInstanceOf(Logger);
71
- // second access covers the cache-hit branch (line 62: !cache.has(this) === false)
72
- expect(instance.logger).toBe(instance.logger);
73
- });
74
-
75
- it("throws a descriptive error when dep is not registered", () => {
76
- class UnknownDep {}
77
- const { ctx, run } = makeFieldCtx("dep");
78
- Inject(UnknownDep)(undefined, ctx);
79
- class MyClass {}
80
- const instance = new MyClass() as Record<string, unknown>;
81
- run(instance);
82
- expect(() => instance.dep).toThrow("[Inject]");
83
- });
84
-
85
- it("warns on direct assignment in non-production", () => {
86
- @Injectable()
87
- class Dep2 {}
88
- const { ctx, run } = makeFieldCtx("dep2");
89
- Inject(Dep2)(undefined, ctx);
90
- const instance: Record<string, unknown> = {};
91
- run(instance);
92
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
93
- instance.dep2 = "override";
94
- expect(spy).toHaveBeenCalledWith(expect.stringContaining("[Inject]"));
95
- spy.mockRestore();
96
- });
97
-
98
- it("returns cached value on second access (cache hit)", () => {
99
- @Injectable()
100
- class CachedService {
101
- id = Math.random();
102
- }
103
-
104
- const { ctx, run } = makeFieldCtx("svc");
105
- Inject(CachedService)(undefined, ctx);
106
- const instance: Record<string, unknown> = {};
107
- run(instance);
108
-
109
- const first = instance.svc;
110
- const second = instance.svc;
111
- expect(first).toBe(second); // same cached instance
112
- });
113
-
114
- it("does not warn on direct assignment in production mode", () => {
115
- @Injectable()
116
- class ProdDep {}
117
- const { ctx, run } = makeFieldCtx("dep");
118
- Inject(ProdDep)(undefined, ctx);
119
- const instance: Record<string, unknown> = {};
120
- run(instance);
121
-
122
- const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
123
- vi.stubEnv("NODE_ENV", "production");
124
- instance.dep = "override";
125
- expect(spy).not.toHaveBeenCalled();
126
-
127
- vi.unstubAllEnvs();
128
- spy.mockRestore();
129
- });
130
-
131
- it("includes the Token description in the error when resolution fails", () => {
132
- const MY_TOKEN = token<string>("MY_SERVICE");
133
- const { ctx, run } = makeFieldCtx("svc");
134
- Inject(MY_TOKEN)(undefined, ctx);
135
- class Consumer {}
136
- const instance = new Consumer() as Record<string, unknown>;
137
- run(instance);
138
- expect(() => instance.svc).toThrow("MY_SERVICE");
139
- });
140
-
141
- it("@Inject on an anonymous class — error message uses token description instead of empty class name", () => {
142
- const ANON_TOKEN = token<string>("ANON_TOKEN");
143
- const { ctx, run } = makeFieldCtx("val");
144
- Inject(ANON_TOKEN)(undefined, ctx);
145
- const instance: Record<string, unknown> = {};
146
- run(instance);
147
- expect(() => instance.val).toThrow("ANON_TOKEN");
148
- });
149
- });
150
-
151
- // ── @InjectContainer ──────────────────────────────────────────────────────────
152
-
153
- describe("InjectContainer", () => {
154
- it("injects the global container on an unscoped class", () => {
155
- const { ctx, run } = makeFieldCtx("c");
156
- InjectContainer()(undefined, ctx);
157
- const instance: Record<string, unknown> = {};
158
- run(instance);
159
- expect(instance.c).toBeInstanceOf(Container);
160
- expect(instance.c).toBe(container);
161
- });
162
-
163
- it("called multiple times on same instance — returns same container instance (cached)", () => {
164
- const { ctx, run } = makeFieldCtx("c");
165
- InjectContainer()(undefined, ctx);
166
- const instance: Record<string, unknown> = {};
167
- run(instance);
168
- const first = instance.c;
169
- const second = instance.c;
170
- expect(first).toBe(second);
171
- });
172
- });
173
-
174
- // ── @Scope ────────────────────────────────────────────────────────────────────
175
-
176
- describe("Scope", () => {
177
- it("creates a child container per instance", () => {
178
- @Scope()
179
- class Widget {}
180
- const { ctx, run } = makeFieldCtx("c");
181
- InjectContainer()(undefined, ctx);
182
-
183
- const a = new Widget() as Record<string, unknown>;
184
- run(a);
185
- const b = new Widget() as Record<string, unknown>;
186
- run(b);
187
-
188
- expect(a.c).toBeInstanceOf(Container);
189
- expect(b.c).toBeInstanceOf(Container);
190
- expect(a.c).not.toBe(b.c); // each instance gets its own scope
191
- expect(a.c).not.toBe(container); // it's a child, not the root
192
- });
193
-
194
- it("services registered in configure are resolvable within the scope", () => {
195
- const DB_URL = token<string>("DB_URL");
196
-
197
- @Scope((c) => {
198
- c.registerValue(DB_URL, "postgres://localhost");
199
- })
200
- class DataModule {}
201
-
202
- const { ctx, run } = makeFieldCtx("db");
203
- Inject(DB_URL)(undefined, ctx);
204
-
205
- const instance = new DataModule() as Record<string, unknown>;
206
- run(instance);
207
-
208
- expect(instance.db).toBe("postgres://localhost");
209
- });
210
-
211
- it("@Inject resolves from the scoped container, not the global one", () => {
212
- class ScopedRepo {}
213
- // NOT registered in global container
214
- const VERSION = token<string>("VERSION");
215
-
216
- @Scope((c) => {
217
- c.register(ScopedRepo);
218
- c.registerValue(VERSION, "2.0");
219
- })
220
- class App {}
221
-
222
- const { ctx: repoCtx, run: runRepo } = makeFieldCtx("repo");
223
- Inject(ScopedRepo)(undefined, repoCtx);
224
- const { ctx: verCtx, run: runVer } = makeFieldCtx("version");
225
- Inject(VERSION)(undefined, verCtx);
226
-
227
- const instance = new App() as Record<string, unknown>;
228
- runRepo(instance);
229
- runVer(instance);
230
-
231
- expect(instance.repo).toBeInstanceOf(ScopedRepo);
232
- expect(instance.version).toBe("2.0");
233
- });
234
-
235
- it("@InjectContainer returns the scoped child container", () => {
236
- @Scope((c) => {
237
- c.registerValue(token<number>("X"), 42);
238
- })
239
- class Service {}
240
-
241
- const { ctx, run } = makeFieldCtx("container");
242
- InjectContainer()(undefined, ctx);
243
-
244
- const instance = new Service() as Record<string, unknown>;
245
- run(instance);
246
-
247
- expect(instance.container).toBeInstanceOf(Container);
248
- expect(instance.container).not.toBe(container);
249
- });
250
-
251
- it("child container inherits registrations from the parent", () => {
252
- @Injectable()
253
- class GlobalService {}
254
-
255
- @Scope()
256
- class Child {}
257
-
258
- const { ctx, run } = makeFieldCtx("svc");
259
- Inject(GlobalService)(undefined, ctx);
260
- const instance = new Child() as Record<string, unknown>;
261
- run(instance);
262
-
263
- expect(instance.svc).toBeInstanceOf(GlobalService);
264
- });
265
-
266
- it("@Scope on child class that inherits from @Scope parent — child gets own container", () => {
267
- const PARENT_TOKEN = token<string>("PARENT_VAL");
268
- const CHILD_TOKEN = token<string>("CHILD_VAL");
269
-
270
- @Scope((c) => {
271
- c.registerValue(PARENT_TOKEN, "parent");
272
- })
273
- class Parent {}
274
-
275
- @Scope((c) => {
276
- c.registerValue(CHILD_TOKEN, "child");
277
- })
278
- class Child extends (Parent as unknown as new () => object) {}
279
-
280
- const { ctx: ctxC, run: runC } = makeFieldCtx("c");
281
- InjectContainer()(undefined, ctxC);
282
-
283
- const parentInstance = new Parent() as Record<string, unknown>;
284
- runC(parentInstance);
285
-
286
- const childInstance = new Child() as Record<string, unknown>;
287
- runC(childInstance);
288
-
289
- expect(parentInstance.c).toBeInstanceOf(Container);
290
- expect(childInstance.c).toBeInstanceOf(Container);
291
- // child has its own scoped container, different from the parent's
292
- expect(childInstance.c).not.toBe(parentInstance.c);
293
- expect(childInstance.c).not.toBe(container);
294
- });
295
- });
296
-
297
- // ── inject() ──────────────────────────────────────────────────────────────────
298
-
299
- describe("inject", () => {
300
- it("resolves a registered class from the global container", () => {
301
- @Injectable()
302
- class GreetService {
303
- greet() { return "hi"; }
304
- }
305
- const svc = inject(GreetService);
306
- expect(svc).toBeInstanceOf(GreetService);
307
- expect(svc.greet()).toBe("hi");
308
- });
309
-
310
- it("returns the same singleton instance on repeated calls", () => {
311
- @Injectable()
312
- class SingletonSvc {}
313
- expect(inject(SingletonSvc)).toBe(inject(SingletonSvc));
314
- });
315
-
316
- it("returns a new instance each time for transient scope", () => {
317
- @Injectable({ scope: "transient" })
318
- class TransientSvc {}
319
- expect(inject(TransientSvc)).not.toBe(inject(TransientSvc));
320
- });
321
-
322
- it("resolves a registered token value", () => {
323
- const API_URL = token<string>("API_URL");
324
- container.registerValue(API_URL, "https://api.example.com");
325
- expect(inject(API_URL)).toBe("https://api.example.com");
326
- });
327
-
328
- it("throws when the class is not registered", () => {
329
- class Unregistered {}
330
- expect(() => inject(Unregistered)).toThrow("[DI]");
331
- });
332
-
333
- it("throws when the token is not registered", () => {
334
- const MISSING = token<string>("MISSING_TOKEN");
335
- expect(() => inject(MISSING)).toThrow("MISSING_TOKEN");
336
- });
337
-
338
- it("result is identical to container.resolve() — same underlying call", () => {
339
- @Injectable()
340
- class EqService {}
341
- expect(inject(EqService)).toBe(container.resolve(EqService));
342
- });
343
- });
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "types": ["node"]
7
- },
8
- "include": ["src"]
9
- }