Skip to content

Testing an Api

Stories check your model. To check what a client can do and see through an Api, sessions and all, call the Api with a real client.

testClient calls the Api in the same process, through its real middleware. Sign each client in with sessionClient, and check every outcome a caller can get.

packages/server/test/access.test.ts
import { expect, it } from "@effect/vitest";
import * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import type * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import { AccountId } from "@bank/domain/Account/Account.id";
import { Bank, BankHandlers } from "@bank/domain/Bank.model";
import { MemoryLedgerStore } from "@bank/domain/Ledger/LedgerStore";
import { CustomerId } from "@bank/domain/Customer/Customer.id";
import { CustomerApi } from "@bank/domain/CustomerApi.api";
import { SiftFraudCheck } from "../src/Fraud/SiftFraudCheck.ts";
import { MfaChallengeLive } from "../src/MfaChallenge.ts";
import { SessionLive } from "../src/Session.ts";
import { sessionClient } from "@bank/domain/Session.middleware";
import { StripePayments } from "../src/Settlement/StripePayments.ts";
import { SupportApi } from "@bank/domain/SupportApi.api";
import { register } from "./flows.ts";
const World = Layer.mergeAll(SessionLive, MfaChallengeLive).pipe(
Layer.provideMerge(Bank.layer(BankHandlers)),
Layer.provide(Layer.mergeAll(Domain.InMemory, SiftFraudCheck, StripePayments, MemoryLedgerStore)),
);
const asCustomer = (id: string) =>
CustomerApi.testClient.pipe(Effect.provide(sessionClient(`customer:${id}`)));
const asAgent = (id: string) =>
SupportApi.testClient.pipe(Effect.provide(sessionClient(`agent:${id}`)));
// Each test gets a fresh bank.
const inBank = <A, E>(effect: Effect.Effect<A, E, Scope.Scope | Layer.Success<typeof World>>) =>
effect.pipe(Effect.scoped, Effect.provide(World));
it.live("only the owner, or an agent, reads an account", () =>
inBank(
Effect.gen(function* () {
const sam = yield* asCustomer("c-1");
const alex = yield* asCustomer("c-2");
const support = yield* asAgent("support-7");
const accountId = yield* register(sam, "c-1", "sam");
expect(yield* sam.AccountSummary.get(accountId)).toMatchObject({ balance: 0 });
expect(yield* support.AccountSummary.get(accountId)).toMatchObject({ balance: 0 });
expect((yield* alex.AccountSummary.get(accountId).pipe(Effect.flip))._tag).toBe("NotOwner");
expect(
(yield* alex.CustomerDashboard.get(CustomerId.make("c-1")).pipe(Effect.flip))._tag,
).toBe("Forbidden");
}),
),
);
it.live("a request without a session is unauthorized", () =>
inBank(
Effect.gen(function* () {
const nobody = yield* CustomerApi.testClient.pipe(Effect.provide(sessionClient("nobody")));
const refused = yield* nobody.AccountSummary.get(AccountId.make("c-1-main")).pipe(Effect.flip);
expect(refused._tag).toBe("Unauthorized");
}),
),
);
it.live("a watch ends with NotOwner when the account changes hands", () =>
inBank(
Effect.gen(function* () {
const sam = yield* asCustomer("c-1");
const alex = yield* asCustomer("c-2");
const support = yield* asAgent("support-7");
const accountId = yield* register(sam, "c-1", "sam");
yield* register(alex, "c-2", "alex");
const watched = yield* sam.AccountSummary.watch(accountId).pipe(
Stream.result,
Stream.take(2),
Stream.runCollect,
Effect.forkChild,
);
yield* Effect.sleep("50 millis");
yield* support.ChangeOwner({ target: accountId, customerId: CustomerId.make("c-2") });
const [first, second] = yield* Fiber.join(watched);
expect(first).toMatchObject({ _tag: "Success", success: { balance: 0 } });
expect(second).toMatchObject({ _tag: "Failure", failure: { _tag: "NotOwner" } });
}),
),
);

register is a helper from the example: it registers a customer, then watches their dashboard until the main account opens.

Serve the Api on a real HTTP server, and call it with the same client a script would use.

packages/server/test/HttpTransport.test.ts
import { Bank, BankHandlers } from "@bank/domain/Bank.model";
import { CustomerId } from "@bank/domain/Customer/Customer.id";
import { CustomerDashboard } from "@bank/domain/Customer/CustomerDashboard.view";
import { CustomerApi } from "@bank/domain/CustomerApi.api";
import { MemoryLedgerStore } from "@bank/domain/Ledger/LedgerStore";
import { sessionClient } from "@bank/domain/Session.middleware";
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import { expect, it } from "@effect/vitest";
import * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import * as HttpServer from "effect/http/HttpServer";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { SiftFraudCheck } from "../src/Fraud/SiftFraudCheck.ts";
import { MfaChallengeLive } from "../src/MfaChallenge.ts";
import { SessionLive } from "../src/Session.ts";
import { StripePayments } from "../src/Settlement/StripePayments.ts";
import { register } from "./flows.ts";
// The Worker's HTTP surface (`yield* CustomerApi`, its `fetch`) on a real HTTP server, over the in-memory platform.
const Ports = Layer.mergeAll(SiftFraudCheck, StripePayments, MemoryLedgerStore);
const World = Layer.mergeAll(SessionLive, MfaChallengeLive).pipe(
Layer.provideMerge(
Bank.layer(BankHandlers).pipe(Layer.provide(Layer.mergeAll(Ports, Domain.InMemory))),
),
);
// A test server on a random port, plus an HttpClient pointed at it.
const Served = Layer.unwrap(Effect.map(CustomerApi, (api) => HttpServer.serve()(api.fetch))).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(World),
);
// The test server serves the Api at its root; its HttpClient already targets the server.
const Client = Layer.mergeAll(Domain.protocolHttp(""), sessionClient("customer:c-1"));
it.live("the CustomerApi round-trips over HTTP, including a streamed watch", () =>
Effect.gen(function* () {
const sam = yield* CustomerApi.client;
const accountId = yield* register(sam, "c-1", "sam");
yield* sam.DepositMoney({ target: accountId, amount: 70 });
const dashboard = yield* sam.CustomerDashboard.watch(CustomerId.make("c-1")).pipe(
Stream.filter((d) => d !== null && CustomerDashboard.computed.totalBalance(d) === 70),
Stream.runHead,
);
expect(accountId as string).toBe("c-1-main");
expect(Option.getOrThrow(dashboard)).toMatchObject({ name: "sam" });
}).pipe(Effect.provide(Client.pipe(Layer.provideMerge(Served))), Effect.scoped),
);

The example’s integration test deploys the whole bank with alchemy/Test/Bun and drives it over the network. A client that isn’t a browser can set headers on its WebSockets, so it sends Authorization on each live read, as it does on every call.

packages/domain/test/liveClient.ts
import * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as Layer from "effect/Layer";
import * as Socket from "effect/socket/Socket";
import { CustomerApi } from "../src/CustomerApi.api.ts";
import { sessionClient } from "../src/Session.middleware.ts";
export const customerClient = (url: string, customerId: string) => {
const token = `customer:${customerId}`;
// Bun's WebSocket accepts headers; a browser sends its session cookie instead.
const webSockets = Layer.succeed(
Socket.WebSocketConstructor,
(socketUrl, protocols) =>
new WebSocket(socketUrl, {
protocols,
headers: { authorization: `Bearer ${token}` },
} as unknown as string[]),
);
return CustomerApi.client.pipe(
Effect.provide(
Layer.mergeAll(Domain.protocolWebSocket(`${url}/api`), sessionClient(token)).pipe(
Layer.provide(Layer.mergeAll(FetchHttpClient.layer, webSockets)),
),
),
);
};

The whole test is in test/integ.test.ts.