diff --git a/README.MD b/README.MD index 5d41571..3c78337 100644 --- a/README.MD +++ b/README.MD @@ -178,6 +178,56 @@ 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 overrides: + +```ts +const fakePayment = createProvider({ + name: "payment", + expose: () => ({ charge: () => "fake" }), +}); + +const app = await createApp({ + root: paymentModule, + overrides: [fakePayment], +}); + +const res = await app.inject({ method: "GET", url: "/pay" }); +assert.strictEqual(res.body, "fake"); +``` + ## 4) Controllers (Routes) Controllers declare routes via a builder. diff --git a/src/container/container.ts b/src/container/container.ts index ae1f8cd..bd55ba5 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; + } + + const override = this.overrides.get(provider.name); + if (override) { + provider = override; + } + + return provider; + } } diff --git a/src/index.test.ts b/src/index.test.ts index e60cfb6..4593a7e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,7 +1,12 @@ -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 () => { @@ -10,21 +15,21 @@ describe("createApp", () => { await app.close(); }); - 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 +38,228 @@ describe("createApp", () => { await fastifyInstance.close(); }); + + describe("overrides", () => { + 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, + overrides: [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, + overrides: [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, + overrides: [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, + overrides: [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..3af1a4c 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; + overrides?: ProviderAny[]; } declare module "fastify" { @@ -32,6 +33,7 @@ export async function createApp({ fastifyInstance, serverOptions, root, + overrides = [], }: 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 overrides) { + overrideMap.set(p.name, p); + } + + const container = new Container(overrideMap); await registerModule(fastify, root, container);