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.
Test in process
Section titled “Test in process”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.
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.
Test over HTTP
Section titled “Test over HTTP”Serve the Api on a real HTTP server, and call it with the same client a script would use.
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),);Test a deployment
Section titled “Test a deployment”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.
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.