Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 29 additions & 10 deletions src/container/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,16 @@ import type {
export class Container {
private singletons = new WeakMap<object, Promise<unknown>>();

constructor(
private readonly overrides: Map<string, ProviderAny> = new Map(),
) {}

async get<ProviderDepsMap extends BaseProviderDepsMap, Value>(
prov: ProviderDef<ProviderDepsMap, Value>,
ctx: ModuleContext,
): Promise<Value> {
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<Value> | undefined;
if (!value) {
value = this.instantiate(provider, ctx);
Expand All @@ -49,4 +44,28 @@ export class Container {

return provider.expose(depsObj as DepValues<ProviderDepsMap>);
}

private resolveProvider<ProviderDepsMap extends BaseProviderDepsMap, Value>(
prov: ProviderDef<ProviderDepsMap, Value>,
ctx: ModuleContext,
): ProviderDef<ProviderDepsMap, Value> {
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;
}
}
243 changes: 236 additions & 7 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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.",
Expand All @@ -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();
});
});
});
Loading