UNPKG

@furystack/inject

Version:

Dependency Injection framework for FuryStack

699 lines 31.2 kB
import { describe, expect, it, vi } from 'vitest'; import { defineService, defineServiceAsync } from './define-service.js'; import { AsyncTokenInSyncContextError, CircularDependencyError, createInjector, Injector, InjectorDisposedError, InvalidLifetimeDependencyError, withScope, } from './injector.js'; describe('Injector', () => { describe('defineService / basic resolution', () => { it('resolves a transient service with a fresh instance per call', () => { const Counter = defineService({ name: 'test/Counter', lifetime: 'transient', factory: () => ({ id: Math.random() }), }); const injector = createInjector(); const a = injector.get(Counter); const b = injector.get(Counter); expect(a).not.toBe(b); }); it('resolves a singleton service as the same instance every time', () => { const Singleton = defineService({ name: 'test/Singleton', lifetime: 'singleton', factory: () => ({ marker: 1 }), }); const injector = createInjector(); expect(injector.get(Singleton)).toBe(injector.get(Singleton)); }); it('resolves a scoped service as per-scope', async () => { const Scoped = defineService({ name: 'test/Scoped', lifetime: 'scoped', factory: () => ({ marker: {} }), }); const root = createInjector(); const childA = root.createScope(); const childB = root.createScope(); expect(childA.get(Scoped)).toBe(childA.get(Scoped)); expect(childA.get(Scoped)).not.toBe(childB.get(Scoped)); await childA[Symbol.asyncDispose](); await childB[Symbol.asyncDispose](); await root[Symbol.asyncDispose](); }); it('caches singletons at the root regardless of requesting scope', async () => { const Single = defineService({ name: 'test/Single', lifetime: 'singleton', factory: () => ({ id: 42 }), }); const root = createInjector(); const child = root.createScope(); const fromChild = child.get(Single); const fromRoot = root.get(Single); expect(fromChild).toBe(fromRoot); await child[Symbol.asyncDispose](); await root[Symbol.asyncDispose](); }); it('runs the factory only once per singleton', () => { const factory = vi.fn(() => ({})); const Token = defineService({ name: 'test/OnceSingleton', lifetime: 'singleton', factory }); const injector = createInjector(); injector.get(Token); injector.get(Token); injector.get(Token); expect(factory).toHaveBeenCalledTimes(1); }); }); describe('inject dependency chain', () => { it('injects a singleton dep into a singleton parent', () => { const Dep = defineService({ name: 'test/Dep', lifetime: 'singleton', factory: () => ({ value: 'dep' }) }); const Parent = defineService({ name: 'test/Parent', lifetime: 'singleton', factory: ({ inject }) => ({ dep: inject(Dep) }), }); const injector = createInjector(); const parent = injector.get(Parent); expect(parent.dep).toBe(injector.get(Dep)); }); it('rejects singleton depending on scoped', () => { const Scoped = defineService({ name: 'test/S', lifetime: 'scoped', factory: () => ({}) }); const Singleton = defineService({ name: 'test/Singleton2', lifetime: 'singleton', factory: ({ inject }) => inject(Scoped), }); const injector = createInjector(); expect(() => injector.get(Singleton)).toThrow(InvalidLifetimeDependencyError); }); it('rejects scoped depending on transient', () => { const Transient = defineService({ name: 'test/T', lifetime: 'transient', factory: () => ({}) }); const Scoped = defineService({ name: 'test/S2', lifetime: 'scoped', factory: ({ inject }) => inject(Transient), }); const injector = createInjector(); expect(() => injector.get(Scoped)).toThrow(InvalidLifetimeDependencyError); }); it('allows transient depending on any lifetime', () => { const Scoped = defineService({ name: 'test/ScopedA', lifetime: 'scoped', factory: () => ({ s: 1 }) }); const Singleton = defineService({ name: 'test/SingletonA', lifetime: 'singleton', factory: () => ({ sg: 1 }) }); const Transient = defineService({ name: 'test/TransientA', lifetime: 'transient', factory: ({ inject }) => ({ a: inject(Scoped), b: inject(Singleton) }), }); const injector = createInjector(); const t = injector.get(Transient); expect(t.a.s).toBe(1); expect(t.b.sg).toBe(1); }); }); describe('circular dependencies', () => { it('throws when a service depends on itself via a two-party cycle', () => { const refs = {}; const A = defineService({ name: 'test/CycleA', lifetime: 'singleton', factory: ({ inject }) => { inject(refs.b); return { name: 'a' }; }, }); const B = defineService({ name: 'test/CycleB', lifetime: 'singleton', factory: ({ inject }) => { inject(refs.a); return { name: 'b' }; }, }); refs.a = A; refs.b = B; const injector = createInjector(); expect(() => injector.get(A)).toThrow(CircularDependencyError); }); it('does not cross-contaminate cycle tracking across independent top-level resolves', () => { const Inner = defineService({ name: 'test/CycleIndependentInner', lifetime: 'transient', factory: () => ({ ok: true }), }); const Outer = defineService({ name: 'test/CycleIndependentOuter', lifetime: 'transient', factory: ({ inject }) => ({ inner: inject(Inner) }), }); const injector = createInjector(); expect(injector.get(Outer).inner.ok).toBe(true); expect(injector.get(Outer).inner.ok).toBe(true); }); }); describe('bind', () => { it('overrides a singleton factory before resolution', () => { const Token = defineService({ name: 'test/BindSingleton', lifetime: 'singleton', factory: () => ({ source: 'default' }), }); const injector = createInjector(); injector.bind(Token, () => ({ source: 'override' })); expect(injector.get(Token).source).toBe('override'); }); it('overrides a scoped factory at the binding scope only', async () => { const Token = defineService({ name: 'test/BindScoped', lifetime: 'scoped', factory: () => ({ source: 'default' }), }); const root = createInjector(); const scope = root.createScope(); scope.bind(Token, () => ({ source: 'override' })); expect(scope.get(Token).source).toBe('override'); expect(root.get(Token).source).toBe('default'); await scope[Symbol.asyncDispose](); await root[Symbol.asyncDispose](); }); it('rebinding drops any cached instance so the next get runs the new factory', () => { const Token = defineService({ name: 'test/BindReplaces', lifetime: 'singleton', factory: () => ({ source: 'default' }), }); const injector = createInjector(); expect(injector.get(Token).source).toBe('default'); injector.bind(Token, () => ({ source: 'override' })); expect(injector.get(Token).source).toBe('override'); }); it('binds a singleton at the root even when called on a child scope', async () => { const Token = defineService({ name: 'test/BindSingletonFromScope', lifetime: 'singleton', factory: () => ({ source: 'default' }), }); const root = createInjector(); const scope = root.createScope(); scope.bind(Token, () => ({ source: 'override' })); expect(root.get(Token).source).toBe('override'); await scope[Symbol.asyncDispose](); await root[Symbol.asyncDispose](); }); it('binds an async factory for an async token', async () => { const Token = defineServiceAsync({ name: 'test/BindAsync', lifetime: 'singleton', factory: async () => ({ source: 'default' }), }); const injector = createInjector(); injector.bind(Token, async () => ({ source: 'override' })); const value = await injector.getAsync(Token); expect(value.source).toBe('override'); }); }); describe('invalidate', () => { it('clears a cached resolved instance so the next get re-runs the factory', () => { let counter = 0; const Token = defineService({ name: 'test/InvalidateResolved', lifetime: 'singleton', factory: () => ({ id: ++counter }), }); const injector = createInjector(); expect(injector.get(Token).id).toBe(1); injector.invalidate(Token); expect(injector.get(Token).id).toBe(2); }); it('clears a cached failure so a retry gets a fresh attempt', () => { let calls = 0; const Token = defineService({ name: 'test/InvalidateFailed', lifetime: 'singleton', factory: () => { calls += 1; if (calls === 1) { throw new Error('boom'); } return { ok: true }; }, }); const injector = createInjector(); expect(() => injector.get(Token)).toThrow('boom'); injector.invalidate(Token); expect(injector.get(Token).ok).toBe(true); expect(calls).toBe(2); }); it('is a no-op for tokens that have never been resolved', () => { const Token = defineService({ name: 'test/InvalidateUnresolved', lifetime: 'singleton', factory: () => ({}), }); const injector = createInjector(); expect(() => injector.invalidate(Token)).not.toThrow(); }); }); describe('error caching', () => { it('caches a factory error and rethrows on subsequent gets', () => { const factory = vi.fn(() => { throw new Error('boom'); }); const Token = defineService({ name: 'test/Boom', lifetime: 'singleton', factory }); const injector = createInjector(); expect(() => injector.get(Token)).toThrow('boom'); expect(() => injector.get(Token)).toThrow('boom'); expect(factory).toHaveBeenCalledTimes(1); }); it('retries transient factories because they are not cached', () => { const factory = vi.fn(() => { throw new Error('boom'); }); const Token = defineService({ name: 'test/BoomTransient', lifetime: 'transient', factory }); const injector = createInjector(); expect(() => injector.get(Token)).toThrow('boom'); expect(() => injector.get(Token)).toThrow('boom'); expect(factory).toHaveBeenCalledTimes(2); }); }); describe('disposal', () => { it('runs onDispose callbacks in LIFO order', async () => { const order = []; const First = defineService({ name: 'test/DisposeFirst', lifetime: 'singleton', factory: ({ onDispose }) => { onDispose(() => { order.push(1); }); return {}; }, }); const Second = defineService({ name: 'test/DisposeSecond', lifetime: 'singleton', factory: ({ onDispose, inject }) => { inject(First); onDispose(() => { order.push(2); }); return {}; }, }); const injector = createInjector(); injector.get(Second); await injector[Symbol.asyncDispose](); expect(order).toEqual([2, 1]); }); it('awaits async onDispose callbacks', async () => { const order = []; const Token = defineService({ name: 'test/AsyncDispose', lifetime: 'singleton', factory: ({ onDispose }) => { onDispose(async () => { await new Promise((r) => setTimeout(r, 10)); order.push('done'); }); return {}; }, }); const injector = createInjector(); injector.get(Token); await injector[Symbol.asyncDispose](); expect(order).toEqual(['done']); }); it('aggregates errors thrown by dispose callbacks', async () => { const Token = defineService({ name: 'test/DisposeError', lifetime: 'singleton', factory: ({ onDispose }) => { onDispose(() => { throw new Error('dispose-fail'); }); return {}; }, }); const injector = createInjector(); injector.get(Token); await expect(injector[Symbol.asyncDispose]()).rejects.toBeInstanceOf(AggregateError); }); it('throws when operating on a disposed injector', async () => { const injector = createInjector(); await injector[Symbol.asyncDispose](); const Token = defineService({ name: 'test/AfterDispose', lifetime: 'singleton', factory: () => ({}) }); expect(() => injector.get(Token)).toThrow(InjectorDisposedError); }); it('is idempotent: disposing twice resolves silently', async () => { const injector = createInjector(); await injector[Symbol.asyncDispose](); await expect(injector[Symbol.asyncDispose]()).resolves.toBeUndefined(); }); it('only runs dispose callbacks once across multiple dispose calls', async () => { const cb = vi.fn(); const Token = defineService({ name: 'test/DisposeOnce', lifetime: 'singleton', factory: ({ onDispose }) => { onDispose(cb); return {}; }, }); const injector = createInjector(); injector.get(Token); await injector[Symbol.asyncDispose](); await injector[Symbol.asyncDispose](); expect(cb).toHaveBeenCalledTimes(1); }); }); describe('withScope', () => { it('disposes the scope when the callback resolves', async () => { const disposed = []; const Scoped = defineService({ name: 'test/WithScopeOK', lifetime: 'scoped', factory: ({ onDispose }) => { onDispose(() => { disposed.push('ok'); }); return {}; }, }); const root = createInjector(); await withScope(root, async (scope) => { scope.get(Scoped); }); expect(disposed).toEqual(['ok']); await root[Symbol.asyncDispose](); }); it('disposes the scope even when the callback throws', async () => { const disposed = []; const Scoped = defineService({ name: 'test/WithScopeThrow', lifetime: 'scoped', factory: ({ onDispose }) => { onDispose(() => { disposed.push('threw'); }); return {}; }, }); const root = createInjector(); await expect(withScope(root, (scope) => { scope.get(Scoped); throw new Error('nope'); })).rejects.toThrow('nope'); expect(disposed).toEqual(['threw']); await root[Symbol.asyncDispose](); }); }); describe('async factories', () => { it('resolves an async service via getAsync', async () => { const Token = defineServiceAsync({ name: 'test/AsyncValue', lifetime: 'singleton', factory: async () => { await Promise.resolve(); return { value: 1 }; }, }); const injector = createInjector(); const value = await injector.getAsync(Token); expect(value.value).toBe(1); }); it('rejects async tokens at the type level for injector.get', () => { const Token = defineServiceAsync({ name: 'test/AsyncInSync', lifetime: 'singleton', factory: async () => 1, }); const injector = createInjector(); // @ts-expect-error async tokens cannot be resolved via the sync get expect(() => injector.get(Token)).toThrow(AsyncTokenInSyncContextError); }); it('shares the pending promise between concurrent callers', async () => { let resolves = 0; const Token = defineServiceAsync({ name: 'test/AsyncShared', lifetime: 'singleton', factory: async () => { resolves += 1; await new Promise((r) => setTimeout(r, 5)); return { id: resolves }; }, }); const injector = createInjector(); const [a, b] = await Promise.all([injector.getAsync(Token), injector.getAsync(Token)]); expect(resolves).toBe(1); expect(a).toBe(b); }); it('caches async errors and rethrows on later getAsync calls', async () => { let calls = 0; const Token = defineServiceAsync({ name: 'test/AsyncBoom', lifetime: 'singleton', factory: async () => { calls += 1; throw new Error('async-boom'); }, }); const injector = createInjector(); await expect(injector.getAsync(Token)).rejects.toThrow('async-boom'); await expect(injector.getAsync(Token)).rejects.toThrow('async-boom'); expect(calls).toBe(1); }); it('resolves sync tokens through getAsync', async () => { const Token = defineService({ name: 'test/SyncViaGetAsync', lifetime: 'singleton', factory: () => ({ value: 7 }), }); const injector = createInjector(); const value = await injector.getAsync(Token); expect(value.value).toBe(7); }); it('caches a sync throw raised from inside an async factory and rethrows on subsequent getAsync', async () => { const factory = vi.fn(() => { throw new Error('async-sync-boom'); }); const Token = defineServiceAsync({ name: 'test/AsyncSyncThrow', lifetime: 'singleton', factory, }); const injector = createInjector(); await expect(injector.getAsync(Token)).rejects.toThrow('async-sync-boom'); await expect(injector.getAsync(Token)).rejects.toThrow('async-sync-boom'); expect(factory).toHaveBeenCalledTimes(1); }); it('detects cycles that form across async boundaries', async () => { const refs = {}; const A = defineServiceAsync({ name: 'test/AsyncCycleA', lifetime: 'singleton', factory: async ({ injectAsync }) => { await Promise.resolve(); await injectAsync(refs.b); return { name: 'a' }; }, }); const B = defineServiceAsync({ name: 'test/AsyncCycleB', lifetime: 'singleton', factory: async ({ injectAsync }) => { await Promise.resolve(); await injectAsync(refs.a); return { name: 'b' }; }, }); refs.a = A; refs.b = B; const injector = createInjector(); await expect(injector.getAsync(A)).rejects.toBeInstanceOf(CircularDependencyError); }); }); describe('token identity', () => { it('mints a distinct symbol per defineService call even when names match', () => { const A = defineService({ name: 'pkg/Same', lifetime: 'singleton', factory: () => ({ v: 1 }) }); const B = defineService({ name: 'pkg/Same', lifetime: 'singleton', factory: () => ({ v: 2 }) }); expect(A.id).not.toBe(B.id); }); }); describe('createScope', () => { it('exposes parent and owner', () => { const root = createInjector(); const scope = root.createScope({ owner: 'request-1' }); expect(scope.parent).toBe(root); expect(scope.owner).toBe('request-1'); expect(root.parent).toBeNull(); }); it('supports constructing an Injector directly as an alternative', () => { const root = new Injector(); expect(root.parent).toBeNull(); }); }); describe('isResolved', () => { it('returns false before the token is resolved', () => { const T = defineService({ name: 'test/NotYet', lifetime: 'singleton', factory: () => ({}) }); const injector = createInjector(); expect(injector.isResolved(T)).toBe(false); }); it('returns true after the token is resolved on the same injector', () => { const T = defineService({ name: 'test/Resolved', lifetime: 'singleton', factory: () => ({}) }); const injector = createInjector(); injector.get(T); expect(injector.isResolved(T)).toBe(true); }); it('returns true from a child scope when the singleton was resolved at the root', () => { const T = defineService({ name: 'test/ResolvedRoot', lifetime: 'singleton', factory: () => ({}) }); const root = createInjector(); root.get(T); const scope = root.createScope(); expect(scope.isResolved(T)).toBe(true); }); it('returns true for async tokens that have already resolved', async () => { const T = defineServiceAsync({ name: 'test/ResolvedAsync', lifetime: 'singleton', factory: async () => ({}), }); const injector = createInjector(); await injector.getAsync(T); expect(injector.isResolved(T)).toBe(true); }); it('throws after disposal', async () => { const T = defineService({ name: 'test/DisposedCheck', lifetime: 'singleton', factory: () => ({}) }); const injector = createInjector(); await injector[Symbol.asyncDispose](); expect(() => injector.isResolved(T)).toThrow(InjectorDisposedError); }); }); describe('async lifetime and cache edge cases', () => { it('rejects an async dep with incompatible lifetime via injectAsync', async () => { const ScopedAsync = defineServiceAsync({ name: 'test/AsyncScopedDep', lifetime: 'scoped', factory: async () => ({ marker: 1 }), }); const SingletonAsync = defineServiceAsync({ name: 'test/AsyncSingletonParent', lifetime: 'singleton', factory: async ({ injectAsync }) => { await injectAsync(ScopedAsync); return {}; }, }); const injector = createInjector(); await expect(injector.getAsync(SingletonAsync)).rejects.toBeInstanceOf(InvalidLifetimeDependencyError); }); it('resolves an async singleton from a child scope by delegating to the root owner', async () => { const factory = vi.fn(async () => ({ id: 'once' })); const AsyncSingleton = defineServiceAsync({ name: 'test/AsyncSingletonOwner', lifetime: 'singleton', factory, }); const root = createInjector(); const scope = root.createScope(); const fromScope = await scope.getAsync(AsyncSingleton); const fromRoot = await root.getAsync(AsyncSingleton); expect(fromScope).toBe(fromRoot); expect(factory).toHaveBeenCalledTimes(1); }); it('returns a cached async value synchronously via getAsync after first resolution', async () => { const AsyncSingleton = defineServiceAsync({ name: 'test/AsyncCached', lifetime: 'singleton', factory: async () => ({ ready: true }), }); const injector = createInjector(); const first = await injector.getAsync(AsyncSingleton); const second = await injector.getAsync(AsyncSingleton); expect(second).toBe(first); }); it('throws AsyncTokenInSyncContextError when a sync injector.get() hits a still-pending async cache entry', async () => { let release; const Pending = defineServiceAsync({ name: 'test/PendingAsyncForSync', lifetime: 'singleton', factory: () => new Promise((resolve) => { release = () => resolve({ ok: true }); }), }); // Sync-side consumer that reaches for the still-pending async entry // via inject(). `get` itself short-circuits async tokens, so this is // the path that actually exercises the `pending` branch of // `consumeCached` (sync context meets pending cache entry). const Consumer = defineService({ name: 'test/SyncConsumerOfPending', lifetime: 'transient', factory: ({ inject }) => inject(Pending), }); const injector = createInjector(); const inflight = injector.getAsync(Pending); expect(() => injector.get(Consumer)).toThrow(AsyncTokenInSyncContextError); release?.(); await inflight; }); }); describe('scoped token cache isolation', () => { it('does not surface an ancestor scope cached value for a scoped token when a descendant rebinds', () => { // Regression: `findCached` used to walk the whole parent chain, so a // `null` cached at an ancestor scope (from resolving the scoped token // with its default factory) masked a descendant `bind()`. This // manifested as `injector.get(FormContextToken)` returning `null` // inside a `<Form>` when any `<Input>` had previously resolved the // same token outside a form on a sibling route. const Token = defineService({ name: 'test/ScopedDefaultNull', lifetime: 'scoped', factory: () => null, }); const root = createInjector(); expect(root.get(Token)).toBeNull(); const child = root.createScope(); child.bind(Token, () => ({ value: 'bound-on-child' })); expect(child.get(Token)).toEqual({ value: 'bound-on-child' }); // Sanity: the ancestor still sees its own cached null -- rebinding // on the child must not reach up and overwrite the parent scope. expect(root.get(Token)).toBeNull(); }); it('gives each scope its own instance for scoped tokens that resolve via default factory', () => { const seeds = []; const Token = defineService({ name: 'test/ScopedPerScope', lifetime: 'scoped', factory: () => { const id = Symbol('scoped-instance'); seeds.push(id); return { id }; }, }); const root = createInjector(); const a = root.createScope(); const b = root.createScope(); const fromA = a.get(Token); const fromB = b.get(Token); const fromRoot = root.get(Token); expect(fromA).not.toBe(fromB); expect(fromA).not.toBe(fromRoot); expect(fromB).not.toBe(fromRoot); expect(seeds).toHaveLength(3); }); it('still shares a single cached singleton across every scope in the chain', () => { const factory = vi.fn(() => ({ marker: 'once' })); const Token = defineService({ name: 'test/SharedSingleton', lifetime: 'singleton', factory, }); const root = createInjector(); const a = root.createScope(); const b = root.createScope(); const fromA = a.get(Token); const fromB = b.get(Token); const fromRoot = root.get(Token); expect(fromA).toBe(fromB); expect(fromA).toBe(fromRoot); expect(factory).toHaveBeenCalledTimes(1); }); }); }); //# sourceMappingURL=injector.spec.js.map