From ca94e6ccecb8bd5ae4cb3b069b990a7318e3fa61 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 1 Nov 2025 13:19:32 +0100 Subject: [PATCH 1/4] feat: allow to override providers --- src/container/container.ts | 39 ++++-- src/index.test.ts | 246 +++++++++++++++++++++++++++++++++++-- src/index.ts | 11 +- 3 files changed, 276 insertions(+), 20 deletions(-) diff --git a/src/container/container.ts b/src/container/container.ts index ae1f8cd..5c58dcd 100644 --- a/src/container/container.ts +++ b/src/container/container.ts @@ -9,21 +9,16 @@ import type { export class Container { private singletons = new WeakMap>(); + constructor( + private readonly overrides: Map = new Map(), + ) {} + async get( prov: ProviderDef, ctx: ModuleContext, ): Promise { - let provider = prov; - if (provider.isContract) { - const bound = ctx.bindings.find((p) => p.name === provider.name); - if (!bound) { - throw new Error( - `Contract provider "${prov.name}" has no binding in module "${ctx.name}".`, - ); - } + const provider = this.resolveProvider(prov, ctx); - provider = bound; - } let value = this.singletons.get(provider) as Promise | undefined; if (!value) { value = this.instantiate(provider, ctx); @@ -49,4 +44,28 @@ export class Container { return provider.expose(depsObj as DepValues); } + + private resolveProvider( + prov: ProviderDef, + ctx: ModuleContext, + ): ProviderDef { + let provider = prov; + + if (provider.isContract) { + const bound = ctx.bindings.find((p) => p.name === provider.name); + if (!bound) { + throw new Error( + `Contract provider "${prov.name}" has no binding in module "${ctx.name}".`, + ); + } + provider = bound as typeof provider; + } + + const override = this.overrides.get(provider.name); + if (override) { + provider = override as typeof provider; + } + + return provider; + } } diff --git a/src/index.test.ts b/src/index.test.ts index e60cfb6..4ce5918 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,30 +1,36 @@ -import { describe, test } from "node:test"; +import { describe, test, TestContext } from "node:test"; import Fastify from "fastify"; -import { createApp, createModule } from "./index"; -import assert from "node:assert"; +import { + contract, + createApp, + createController, + createModule, + createProvider, +} from "./index"; describe("createApp", () => { - test("should create a fastify instance", async () => { + test("should create a fastify instance", async (t: TestContext) => { const root = createModule({ name: "root" }); const app = await createApp({ root }); await app.close(); + t.assert.ok(app); }); - test("should use fastifyInstance, if provided ", async () => { + test("should use fastifyInstance, if provided ", async (t: TestContext) => { const fastifyInstance = Fastify(); const root = createModule({ name: "root" }); const app = await createApp({ fastifyInstance, root }); await app.close(); - assert.strictEqual(app, fastifyInstance); + t.assert.strictEqual(app, fastifyInstance); }); - test("should throw if both fastifyInstance and serverOptions are provided", async () => { + test("should throw if both fastifyInstance and serverOptions are provided", async (t: TestContext) => { const fastifyInstance = Fastify(); const root = createModule({ name: "root" }); - await assert.rejects( + await t.assert.rejects( () => createApp({ fastifyInstance, serverOptions: {}, root }), { message: "Either provide fastifyInstance or serverOptions, not both.", @@ -33,4 +39,228 @@ describe("createApp", () => { await fastifyInstance.close(); }); + + describe("overriders", () => { + test("should override a simple provider by name", async (t: TestContext) => { + const realPayment = createProvider({ + name: "payment", + expose: () => ({ charge: () => "real" }), + }); + + const fakePayment = createProvider({ + name: "payment", + expose: () => ({ charge: () => "fake" }), + }); + + const paymentConsumer = createController({ + name: "payment-consumer", + deps: { payment: realPayment }, + build: ({ builder, deps }) => { + builder.addRoute({ + method: "GET", + url: "/pay", + handler: async () => deps.payment.charge(), + }); + }, + }); + + const root = createModule({ + name: "root", + controllers: [paymentConsumer], + }); + + const app = await createApp({ + root, + overriders: [fakePayment], + }); + + const res = await app.inject({ method: "GET", url: "/pay" }); + t.assert.strictEqual(res.statusCode, 200); + t.assert.strictEqual(res.body, "fake"); + + await app.close(); + }); + + test("should override a provider that is a dependency of another provider", async (t: TestContext) => { + const realUsers = createProvider({ + name: "usersRepository", + expose: () => ({ + all: () => ["real-user"], + }), + }); + + const profiles = createProvider({ + name: "profiles", + deps: { usersRepository: realUsers }, + expose: ({ usersRepository }) => ({ + list: () => usersRepository.all().map((u: string) => `profile-${u}`), + }), + }); + + const fakeUsers = createProvider({ + name: "usersRepository", + expose: () => ({ + all: () => ["fake-user-1", "fake-user-2"], + }), + }); + + const controller = createController({ + name: "profiles-controller", + deps: { profiles }, + build: ({ builder, deps }) => { + builder.addRoute({ + method: "GET", + url: "/profiles", + handler: async () => deps.profiles.list(), + }); + }, + }); + + const root = createModule({ + name: "root", + controllers: [controller], + }); + + const app = await createApp({ + root, + overriders: [fakeUsers], + }); + + const res = await app.inject({ method: "GET", url: "/profiles" }); + t.assert.strictEqual(res.statusCode, 200); + t.assert.deepStrictEqual(res.json(), [ + "profile-fake-user-1", + "profile-fake-user-2", + ]); + + await app.close(); + }); + + test("should override a contract binding", async (t: TestContext) => { + const MAILER_TOKEN = "mailer"; + const Mailer = contract<{ send(): void }>(MAILER_TOKEN); + + const realMailer = createProvider({ + name: MAILER_TOKEN, + expose: () => ({ + send: () => { + throw new Error("should be overridden"); + }, + }), + }); + + let callFake = false; + const fakeMailer = createProvider({ + name: MAILER_TOKEN, + expose: () => ({ + send: () => { + callFake = true; + }, + }), + }); + + const sendWelcome = createProvider({ + name: "send-welcome", + deps: { mailer: Mailer }, + expose: ({ mailer }) => ({ + run: () => mailer.send(), + }), + }); + + const ctrl = createController({ + name: "welcome", + deps: { sendWelcome }, + build: ({ builder, deps }) => { + builder.addRoute({ + method: "POST", + url: "/welcome", + handler: async () => { + deps.sendWelcome.run(); + return { ok: true }; + }, + }); + }, + }); + + const notifications = createModule({ + name: "notifications", + controllers: [ctrl], + bindings: [realMailer], + }); + + const root = createModule({ + name: "root", + subModules: [notifications], + }); + + const app = await createApp({ + root, + overriders: [fakeMailer], + }); + + const res = await app.inject({ + method: "POST", + url: "/welcome", + }); + + t.assert.strictEqual(res.statusCode, 200); + t.assert.ok(callFake); + + await app.close(); + }); + + test("should instantiate only the override, not the original", async (t: TestContext) => { + let realCount = 0; + let fakeCount = 0; + + const realProv = createProvider({ + name: "counter", + expose: () => { + realCount += 1; + return { kind: "real" }; + }, + }); + + const fakeProv = createProvider({ + name: "counter", + expose: () => { + fakeCount += 1; + return { kind: "fake" }; + }, + }); + + const consumerA = createProvider({ + name: "consumerA", + deps: { counter: realProv }, + expose: ({ counter }) => counter, + }); + + const consumerB = createProvider({ + name: "consumerB", + deps: { counter: realProv }, + expose: ({ counter }) => counter, + }); + + const root = createModule({ + name: "root", + controllers: [ + createController({ + name: "probe", + deps: { consumerA, consumerB }, + build: () => {}, + }), + ], + }); + + const app = await createApp({ + root, + overriders: [fakeProv], + }); + + t.assert.strictEqual(realCount, 0); + t.assert.strictEqual(fakeCount, 1); + + await app.close(); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 1976637..3fc2f44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,9 +15,10 @@ import { describeTree } from "./printer/describe-tree"; import { getModuleId, ModuleAny, registerModule } from "./modules"; export interface CreateAppOptions { - fastifyInstance?: FastifyInstance; root: ModuleAny; + fastifyInstance?: FastifyInstance; serverOptions?: FastifyServerOptions; + overriders?: ProviderAny[]; } declare module "fastify" { @@ -32,6 +33,7 @@ export async function createApp({ fastifyInstance, serverOptions, root, + overriders = [], }: CreateAppOptions): Promise { if (fastifyInstance && serverOptions) { throw new Error( @@ -59,7 +61,12 @@ export async function createApp({ } }); - const container = new Container(); + const overrideMap = new Map(); + for (const p of overriders) { + overrideMap.set(p.name, p); + } + + const container = new Container(overrideMap); await registerModule(fastify, root, container); From f8ee99ee1e53b8f48b1ff79601d6fe4705fbf4ba Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 1 Nov 2025 13:31:14 +0100 Subject: [PATCH 2/4] docs: document the new feature --- README.MD | 53 +++++++++++++++++++++++++++++++++++++- src/container/container.ts | 4 +-- src/index.test.ts | 3 +-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.MD b/README.MD index 5d41571..b7d3313 100644 --- a/README.MD +++ b/README.MD @@ -178,6 +178,57 @@ const testProvider = profilesProvider.withProviders((deps) => ({ const profiles = await testProvider.resolve(); ``` +### Global Provider Overrides + +Stratify supports **global provider overrides** when bootstrapping the app. + +This is useful for integration and e2e tests where you need to +replace real dependencies with fakes without reconstructing your application tree. + +Example of module: + +```ts +const realPayment = createProvider({ + name: "payment", + expose: () => ({ charge: () => "real" }), +}); + +const paymentController = createController({ + name: "payment", + deps: { payment: realPayment }, + build: ({ builder, deps }) => { + builder.addRoute({ + method: "GET", + url: "/pay", + handler: async () => deps.payment.charge(), + }); + }, +}); + +const paymentModule = createModule({ + name: "payment-module", + controllers: [paymentController], +}); +``` + +Then create the app with overriders: + +```ts +const fakePayment = createProvider({ + name: "payment", + expose: () => ({ charge: () => "fake" }), +}); + +const app = await createApp({ + root: paymentModule, + overriders: [fakePayment], +}); + +const res = await app.inject({ method: "GET", url: "/pay" }); +assert.strictEqual(res.body, "fake"); +``` + + ## 4) Controllers (Routes) Controllers declare routes via a builder. @@ -213,7 +264,7 @@ const userController = createController({ }); }, }); -``` +```` Attach to a module: diff --git a/src/container/container.ts b/src/container/container.ts index 5c58dcd..bd55ba5 100644 --- a/src/container/container.ts +++ b/src/container/container.ts @@ -58,12 +58,12 @@ export class Container { `Contract provider "${prov.name}" has no binding in module "${ctx.name}".`, ); } - provider = bound as typeof provider; + provider = bound; } const override = this.overrides.get(provider.name); if (override) { - provider = override as typeof provider; + provider = override; } return provider; diff --git a/src/index.test.ts b/src/index.test.ts index 4ce5918..e0bdddc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,11 +9,10 @@ import { } from "./index"; describe("createApp", () => { - test("should create a fastify instance", async (t: TestContext) => { + test("should create a fastify instance", async () => { const root = createModule({ name: "root" }); const app = await createApp({ root }); await app.close(); - t.assert.ok(app); }); test("should use fastifyInstance, if provided ", async (t: TestContext) => { From 4cb7f029cf16f6d9cb0c1d09c1afc91c95dffeb9 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 1 Nov 2025 13:34:20 +0100 Subject: [PATCH 3/4] refactor: update override property name --- README.MD | 6 +++--- src/index.test.ts | 10 +++++----- src/index.ts | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.MD b/README.MD index b7d3313..07116ba 100644 --- a/README.MD +++ b/README.MD @@ -180,7 +180,7 @@ const profiles = await testProvider.resolve(); ### Global Provider Overrides -Stratify supports **global provider overrides** when bootstrapping the app. +Stratify supports global provider overrides when bootstrapping the app. This is useful for integration and e2e tests where you need to replace real dependencies with fakes without reconstructing your application tree. @@ -211,7 +211,7 @@ const paymentModule = createModule({ }); ``` -Then create the app with overriders: +Then create the app with overrides: ```ts const fakePayment = createProvider({ @@ -221,7 +221,7 @@ const fakePayment = createProvider({ const app = await createApp({ root: paymentModule, - overriders: [fakePayment], + overrides: [fakePayment], }); const res = await app.inject({ method: "GET", url: "/pay" }); diff --git a/src/index.test.ts b/src/index.test.ts index e0bdddc..4593a7e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -39,7 +39,7 @@ describe("createApp", () => { await fastifyInstance.close(); }); - describe("overriders", () => { + describe("overrides", () => { test("should override a simple provider by name", async (t: TestContext) => { const realPayment = createProvider({ name: "payment", @@ -70,7 +70,7 @@ describe("createApp", () => { const app = await createApp({ root, - overriders: [fakePayment], + overrides: [fakePayment], }); const res = await app.inject({ method: "GET", url: "/pay" }); @@ -122,7 +122,7 @@ describe("createApp", () => { const app = await createApp({ root, - overriders: [fakeUsers], + overrides: [fakeUsers], }); const res = await app.inject({ method: "GET", url: "/profiles" }); @@ -194,7 +194,7 @@ describe("createApp", () => { const app = await createApp({ root, - overriders: [fakeMailer], + overrides: [fakeMailer], }); const res = await app.inject({ @@ -253,7 +253,7 @@ describe("createApp", () => { const app = await createApp({ root, - overriders: [fakeProv], + overrides: [fakeProv], }); t.assert.strictEqual(realCount, 0); diff --git a/src/index.ts b/src/index.ts index 3fc2f44..3af1a4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export interface CreateAppOptions { root: ModuleAny; fastifyInstance?: FastifyInstance; serverOptions?: FastifyServerOptions; - overriders?: ProviderAny[]; + overrides?: ProviderAny[]; } declare module "fastify" { @@ -33,7 +33,7 @@ export async function createApp({ fastifyInstance, serverOptions, root, - overriders = [], + overrides = [], }: CreateAppOptions): Promise { if (fastifyInstance && serverOptions) { throw new Error( @@ -62,7 +62,7 @@ export async function createApp({ }); const overrideMap = new Map(); - for (const p of overriders) { + for (const p of overrides) { overrideMap.set(p.name, p); } From 1e69449b6564e7631bd4a907735c1dcacc9c7aed Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 1 Nov 2025 13:37:16 +0100 Subject: [PATCH 4/4] fix: lint --- README.MD | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.MD b/README.MD index 07116ba..3c78337 100644 --- a/README.MD +++ b/README.MD @@ -228,7 +228,6 @@ const res = await app.inject({ method: "GET", url: "/pay" }); assert.strictEqual(res.body, "fake"); ``` - ## 4) Controllers (Routes) Controllers declare routes via a builder. @@ -264,7 +263,7 @@ const userController = createController({ }); }, }); -```` +``` Attach to a module: