Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
# @suaveplan/testing-integration-mailpit - Mailpit testcontainers fixture + assertMail helper for @suaveplan/testing-integration
## Summary
Mailpit testcontainers fixture + `assertMail()` helper for `@suaveplan/testing-integration`.
Implements `ServiceFixture<MailpitConfig>` — manages a Mailpit container lifecycle with SMTP and API ports, env-bridge passthrough, and API health checks.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-mailpit
```
## Quick Start
```typescript
import { MailpitFixture, assertMail } from "@suaveplan/testing-integration-mailpit";
const fixture = new MailpitFixture();
const config = await fixture.start();
// config.smtpUrl, config.apiUrl
// Send email to config.smtpUrl...
await assertMail(config.apiUrl, { to: "user@example.com", subject: "Welcome" });
await fixture.stop();
```
## Env-bridge passthrough
Set both env vars to skip container startup:
```text
MAILPIT_SMTP_URL=smtp://localhost:1025
MAILPIT_API_URL=http://localhost:8025
```
## Docs
- [MailpitFixture](src/fixture/mailpit-fixture.md)
- [assertMail](src/helpers/assert-mail.md)

View File

@@ -0,0 +1,81 @@
{
"name": "@suaveplan/testing-integration-mailpit",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Mailpit testcontainers fixture + assertMail helper for @suaveplan/testing-integration",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./fixture/mailpit-fixture": {
"types": "./dist/fixture/mailpit-fixture.d.ts",
"default": "./dist/fixture/mailpit-fixture.js"
},
"./helpers/assert-mail": {
"types": "./dist/helpers/assert-mail.d.ts",
"default": "./dist/helpers/assert-mail.js"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"zod": "catalog:forms"
},
"peerDependencies": {
"@suaveplan/testing-integration": "workspace:^",
"testcontainers": ">=11.0.0"
},
"peerDependenciesMeta": {
"testcontainers": {
"optional": true
}
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"testcontainers": "catalog:containers",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,42 @@
/** Default Docker image for Mailpit. */
export const MAILPIT_IMAGE = "axllent/mailpit:latest";
/** SMTP port exposed by Mailpit. */
export const MAILPIT_SMTP_PORT = 1025;
/** Web/API port exposed by Mailpit. */
export const MAILPIT_API_PORT = 8025;
/** Maximum time to wait for Mailpit to become healthy. */
export const MAILPIT_HEALTH_TIMEOUT_MS = 60_000;
/** Health check poll interval in milliseconds. */
export const MAILPIT_HEALTH_POLL_INTERVAL_MS = 1_000;
/** Default poll interval when waiting for an email in assertMail(). */
export const ASSERT_MAIL_POLL_INTERVAL_MS = 500;
/** Default total timeout when waiting for an email in assertMail(). */
export const ASSERT_MAIL_TIMEOUT_MS = 10_000;
/** Env vars checked for passthrough mode — ALL must be set. */
export const MAILPIT_ENV_VARS = [
"MAILPIT_SMTP_URL",
"MAILPIT_API_URL",
] as const;
/**
* Log line emitted by Mailpit when its HTTP API binds and the server is
* ready to accept requests. Used as a Bun-compatible readiness signal in
* place of `Wait.forListeningPorts()`, which hangs under Bun
* (see https://github.com/oven-sh/bun/issues/21342).
*/
export const MAILPIT_READY_LOG = "[http] starting on";
/**
* Number of times {@link MAILPIT_READY_LOG} should appear before Mailpit
* is considered ready. Mailpit emits the line exactly once per startup.
* See https://github.com/oven-sh/bun/issues/21342 for context on why we
* use a log-message wait strategy under Bun.
*/
export const MAILPIT_READY_LOG_TIMES = 1;

View File

@@ -0,0 +1,5 @@
export { MailpitFixture } from "./mailpit-fixture.js";
export type {
MailpitConfig,
MailpitFixtureOptions,
} from "./mailpit-fixture.types.js";

View File

@@ -0,0 +1,110 @@
# MailpitFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MailpitFixture` is an integration test fixture that manages the full lifecycle of a Mailpit container using testcontainers. It implements the `ServiceFixture<MailpitConfig>` interface from `@suaveplan/testing-integration`, providing an SMTP server and REST API for email testing. The fixture binds two ports: SMTP on 1025 and the web/API on 8025.
The fixture integrates with the `assertMail()` helper exported from the same package, enabling tests to send emails via SMTP and then verify delivery through the Mailpit REST API.
## Features
- **Env-bridge passthrough**: when both `MAILPIT_SMTP_URL` and `MAILPIT_API_URL` are set and non-empty, the fixture returns them directly. Partial env sets (only one variable) fall through to container mode.
- **Dual-port binding**: exposes both SMTP (1025) and API (8025) ports, mapping each to an ephemeral host port.
- **API health check**: polls `GET /api/v1/messages` on the web port. Accepts a 200 response as healthy.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` in health checks for unit testing.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
- **Idempotent stop**: safe to call on an already-stopped or passthrough fixture.
## Usage: Basic Example
```typescript
import { MailpitFixture } from "@suaveplan/testing-integration-mailpit";
const fixture = new MailpitFixture();
const config = await fixture.start();
// config.smtpUrl → "smtp://localhost:<randomSmtpPort>"
// config.apiUrl → "http://localhost:<randomApiPort>"
// Send an email to SMTP, then use assertMail() to verify it arrived.
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import {
MailpitFixture,
assertMail,
} from "@suaveplan/testing-integration-mailpit";
import nodemailer from "nodemailer";
const fixture = new MailpitFixture({ image: "axllent/mailpit:latest" });
const config = await fixture.start();
const transporter = nodemailer.createTransport({ url: config.smtpUrl });
await transporter.sendMail({
from: "noreply@example.com",
to: "user@example.com",
subject: "Welcome to SuavePlan",
text: "Hello!",
});
await assertMail(config.apiUrl, {
to: "user@example.com",
subject: "Welcome",
});
await fixture.stop();
```
## API Reference
### `class MailpitFixture`
Implements `ServiceFixture<MailpitConfig>`.
#### `constructor(options?: MailpitFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"mailpit"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<MailpitConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode, returns immediately with env var values. Otherwise starts a testcontainers container.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Returns `true` when `GET <apiUrl>/api/v1/messages` returns an OK response. Returns `false` if no container is active or the request fails.
### `interface MailpitConfig`
| Field | Type | Description |
|-------|------|-------------|
| `smtpUrl` | `string` | SMTP URL: `smtp://localhost:<port>` |
| `apiUrl` | `string` | API URL: `http://localhost:<port>` |
### `interface MailpitFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `axllent/mailpit:latest`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `fetchFn?` | `typeof fetch` | Injectable fetch for health check unit testing. |
## Implementation Notes
- **Why API health check over SMTP**: the Mailpit REST API provides a reliable readiness signal. Attempting an SMTP connect and transaction would be heavier and require parsing SMTP protocol responses.
- **Partial passthrough handling**: the env-bridge only activates when BOTH `MAILPIT_SMTP_URL` and `MAILPIT_API_URL` are set. This prevents silently returning an incomplete config if one variable is set accidentally.
- **Dynamic import of testcontainers**: deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.

View File

@@ -0,0 +1,181 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { MailpitFixture } from "./mailpit-fixture.js";
function makeContainerFake(smtpPort = 1025, apiPort = 8025) {
const stopped: string[] = [];
const portMap: Record<number, number> = {
1025: smtpPort,
8025: apiPort,
};
const fakeStarted = {
getMappedPort: (port: number) => portMap[port] ?? port,
stop: async () => {
stopped.push("stopped");
},
};
const fakeContainer = {
withExposedPorts: function () {
return this;
},
withWaitStrategy: function () {
return this;
},
start: async () => fakeStarted,
};
return {
fakeContainer,
fakeStarted,
stopped,
factory: (_img: string) => fakeContainer as never,
};
}
describe("MailpitFixture", () => {
beforeEach(() => {
delete process.env["MAILPIT_SMTP_URL"];
delete process.env["MAILPIT_API_URL"];
});
afterEach(() => {
delete process.env["MAILPIT_SMTP_URL"];
delete process.env["MAILPIT_API_URL"];
});
it("name property returns mailpit", { timeout: 5000 }, () => {
const fixture = new MailpitFixture();
expect(fixture.name).toBe("mailpit");
});
it("passthrough mode: returns config from both env vars", {
timeout: 30000,
}, async () => {
process.env["MAILPIT_SMTP_URL"] = "smtp://localhost:1025";
process.env["MAILPIT_API_URL"] = "http://localhost:8025";
const fixture = new MailpitFixture();
const config = await fixture.start();
expect(config.smtpUrl).toBe("smtp://localhost:1025");
expect(config.apiUrl).toBe("http://localhost:8025");
});
it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => {
process.env["MAILPIT_SMTP_URL"] = "smtp://localhost:1025";
process.env["MAILPIT_API_URL"] = "http://localhost:8025";
const fixture = new MailpitFixture();
await fixture.start();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("partial passthrough (only SMTP set) falls through to container mode", {
timeout: 30000,
}, async () => {
process.env["MAILPIT_SMTP_URL"] = "smtp://localhost:1025";
// MAILPIT_API_URL not set
const { factory } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({ containerFactory: factory });
const config = await fixture.start();
// Should have used the container, not env var
expect(config.smtpUrl).toBe("smtp://localhost:11025");
expect(config.apiUrl).toBe("http://localhost:18025");
});
it("container mode happy path: returns correct URLs", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({ containerFactory: factory });
const config = await fixture.start();
expect(config.smtpUrl).toBe("smtp://localhost:11025");
expect(config.apiUrl).toBe("http://localhost:18025");
});
it("container mode: stop calls container.stop()", {
timeout: 30000,
}, async () => {
const { factory, stopped } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({ containerFactory: factory });
await fixture.start();
await fixture.stop();
expect(stopped).toContain("stopped");
});
it("healthCheck returns true via injected fetchFn", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({
containerFactory: factory,
fetchFn: async () => ({ ok: true }) as Response,
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(true);
});
it("healthCheck returns false via injected fetchFn (ok: false)", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({
containerFactory: factory,
fetchFn: async () => ({ ok: false }) as Response,
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("healthCheck returns false when fetch throws", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(11025, 18025);
const fixture = new MailpitFixture({
containerFactory: factory,
fetchFn: async () => {
throw new Error("ECONNREFUSED");
},
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("healthCheck returns false when no container", {
timeout: 30000,
}, async () => {
const fixture = new MailpitFixture();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("stop when no container is a no-op", { timeout: 30000 }, async () => {
const fixture = new MailpitFixture();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("resolveContainerFactory: falls back to GenericContainer import", {
timeout: 60000,
}, async () => {
class Exposed extends MailpitFixture {
resolveFactory() {
return this.resolveContainerFactory();
}
}
const exposed = new Exposed();
const factory = await exposed.resolveFactory();
expect(typeof factory).toBe("function");
const container = factory("axllent/mailpit:latest");
expect(container).toBeDefined();
});
});

View File

@@ -0,0 +1,113 @@
/**
* MailpitFixture — testcontainers-backed ServiceFixture for Mailpit.
*
* @module
*/
import {
applyWaitStrategy,
type ServiceFixture,
} from "@suaveplan/testing-integration";
import type { GenericContainer, StartedTestContainer } from "testcontainers";
import {
MAILPIT_API_PORT,
MAILPIT_ENV_VARS,
MAILPIT_IMAGE,
MAILPIT_READY_LOG,
MAILPIT_READY_LOG_TIMES,
MAILPIT_SMTP_PORT,
} from "../constants/index.js";
import type {
MailpitConfig,
MailpitFixtureOptions,
} from "./mailpit-fixture.types.js";
/**
* Manages the full lifecycle of a Mailpit container for integration tests.
*
* Supports env-bridge passthrough mode: when both `MAILPIT_SMTP_URL` and
* `MAILPIT_API_URL` are set and non-empty, the fixture returns them directly
* without starting a container. Partial env var sets (only one of the two)
* fall through to container mode.
*
* The health check uses the Mailpit REST API endpoint `GET /api/v1/messages`.
*/
export class MailpitFixture implements ServiceFixture<MailpitConfig> {
readonly name = "mailpit";
protected readonly options: MailpitFixtureOptions;
protected container: StartedTestContainer | null = null;
protected config: MailpitConfig | null = null;
protected passthrough = false;
constructor(options: MailpitFixtureOptions = {}) {
this.options = options;
}
/**
* Resolves the container factory. Returns the injected factory when provided,
* otherwise dynamically imports testcontainers and wraps GenericContainer.
*/
protected async resolveContainerFactory(): Promise<
(image: string) => GenericContainer
> {
if (this.options.containerFactory) {
return this.options.containerFactory;
}
const { GenericContainer } = await import("testcontainers");
return (img: string) => new GenericContainer(img);
}
async start(): Promise<MailpitConfig> {
// Env-bridge passthrough mode — BOTH vars must be set and non-empty
const [smtpUrl, apiUrl] = MAILPIT_ENV_VARS.map(
(v) => process.env[v] ?? ""
);
if (smtpUrl && apiUrl) {
this.passthrough = true;
this.config = { smtpUrl, apiUrl };
return this.config;
}
const image = this.options.image ?? MAILPIT_IMAGE;
const factory = await this.resolveContainerFactory();
const builder = factory(image).withExposedPorts(
MAILPIT_SMTP_PORT,
MAILPIT_API_PORT
);
const raw = await applyWaitStrategy(builder, {
readyLog: MAILPIT_READY_LOG,
times: MAILPIT_READY_LOG_TIMES,
});
this.container = await raw.start();
const smtpPort = this.container.getMappedPort(MAILPIT_SMTP_PORT);
const apiPort = this.container.getMappedPort(MAILPIT_API_PORT);
this.config = {
smtpUrl: `smtp://localhost:${smtpPort}`,
apiUrl: `http://localhost:${apiPort}`,
};
return this.config;
}
async stop(): Promise<void> {
if (this.passthrough || !this.container) return;
await this.container.stop();
this.container = null;
}
async healthCheck(): Promise<boolean> {
if (!this.container) return false;
const apiPort = this.container.getMappedPort(MAILPIT_API_PORT);
const apiUrl = `http://localhost:${apiPort}`;
try {
const fetchFn =
this.options.fetchFn ??
/* istanbul ignore next */ globalThis.fetch;
const res = await fetchFn(`${apiUrl}/api/v1/messages`);
return res.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,17 @@
import type { GenericContainer } from "testcontainers";
export interface MailpitConfig extends Record<string, unknown> {
/** SMTP URL: "smtp://localhost:<smtpPort>" */
smtpUrl: string;
/** HTTP API URL: "http://localhost:<apiPort>" */
apiUrl: string;
}
export interface MailpitFixtureOptions {
/** Override the Docker image. Defaults to MAILPIT_IMAGE constant. */
image?: string;
/** Injectable container factory for unit testing. */
containerFactory?: (image: string) => GenericContainer;
/** Injectable fetch for healthCheck unit testing. */
fetchFn?: typeof fetch;
}

View File

@@ -0,0 +1,104 @@
# assertMail
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`assertMail()` is a test assertion helper that polls the Mailpit REST API and fails tests that do not receive an expected email within a configurable timeout. It bridges the gap between sending an email via SMTP and verifying delivery in integration tests.
The helper is designed for use with `MailpitFixture` but works with any Mailpit instance accessible at a known HTTP URL. Its injectable `fetchFn` option makes it fully unit-testable without a live Mailpit server.
## Features
- **Polling-based**: retries at a configurable interval until the deadline, tolerating normal async email delivery latency.
- **Exact address matching**: verifies `To[].Address` exactly matches the provided `to` address.
- **Substring subject matching**: checks that the message `Subject` includes the provided `subject` string, allowing partial matches.
- **Configurable timeout and interval**: `timeoutMs` and `pollIntervalMs` options allow fast-failing in unit tests (e.g. 50ms timeout) and longer waits in real environments.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` for deterministic unit tests with no live network dependency.
- **Structured error message**: the timeout error includes the `to` and `subject` criteria and timeout value for easy debugging.
## Usage: Basic Example
```typescript
import { assertMail } from "@suaveplan/testing-integration-mailpit";
// After sending an email via SMTP...
await assertMail("http://localhost:8025", {
to: "user@example.com",
subject: "Welcome",
});
// Resolves when a matching message is found; throws after 10s otherwise.
```
## Usage: Advanced Example
```typescript
import {
MailpitFixture,
assertMail,
} from "@suaveplan/testing-integration-mailpit";
import nodemailer from "nodemailer";
const fixture = new MailpitFixture();
const config = await fixture.start();
const transporter = nodemailer.createTransport({ url: config.smtpUrl });
await transporter.sendMail({
from: "noreply@example.com",
to: "alice@example.com",
subject: "Password Reset Request",
text: "Click here to reset your password.",
});
// Custom timeout: wait up to 5 seconds, polling every 200ms
await assertMail(config.apiUrl, {
to: "alice@example.com",
subject: "Password Reset",
}, {
timeoutMs: 5000,
pollIntervalMs: 200,
});
await fixture.stop();
```
## API Reference
### `assertMail(apiUrl, criteria, options?): Promise<void>`
Polls the Mailpit API until a matching message is found or the timeout elapses.
#### Parameters
| Name | Type | Description |
|------|------|-------------|
| `apiUrl` | `string` | Mailpit API base URL (e.g. `http://localhost:8025`). |
| `criteria` | `AssertMailCriteria` | Matching criteria. |
| `options?` | `AssertMailOptions` | Optional configuration. |
#### Throws
`Error` when no matching message is found within `timeoutMs`. The message includes the `to` and `subject` values and elapsed timeout.
### `interface AssertMailCriteria`
| Field | Type | Description |
|-------|------|-------------|
| `to` | `string` | Exact recipient address to match in `To[].Address`. |
| `subject` | `string` | Subject substring to match via `String.includes()`. |
### `interface AssertMailOptions`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `timeoutMs?` | `number` | `10_000` | Total wait time in ms. |
| `pollIntervalMs?` | `number` | `500` | Polling interval in ms. |
| `fetchFn?` | `typeof fetch` | `globalThis.fetch` | Injectable fetch for unit tests. |
## Implementation Notes
- **Polling strategy**: the helper uses a `while (Date.now() < deadline)` loop with `setTimeout` between iterations. This provides predictable behavior without relying on `setInterval` side effects.
- **Mailpit API shape**: the `/api/v1/messages` endpoint returns `{ messages: Array<{ To: [{Address: string}], Subject: string, ... }> }`. The `messages` key may be absent on an empty inbox; the helper defaults to `[]`.
- **Partial subject matching rationale**: email subjects often contain dynamic content (timestamps, user names) appended to a fixed prefix. Substring matching lets tests assert on the stable prefix without hardcoding dynamic parts.
- **Exact address matching rationale**: recipient addresses are authoritative identifiers. Substring matching on addresses would be error-prone (e.g. `user@example.com` matching `user@example.company`).

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TestEnvironmentError } from "@suaveplan/testing-integration";
import { assertMail } from "./assert-mail.js";
type MailMessage = {
To: Array<{ Address: string }>;
Subject: string;
};
function makeFetch(
responses: Array<{ ok: boolean; status?: number; messages?: MailMessage[] }>
) {
let callIndex = 0;
return async (_url: string | URL | Request): Promise<Response> => {
const response = responses[Math.min(callIndex++, responses.length - 1)];
if (!response) throw new Error("No more responses");
return {
ok: response.ok,
status: response.status ?? (response.ok ? 200 : 500),
json: async () => ({ messages: response.messages ?? [] }),
} as unknown as Response;
};
}
const matchingMessage: MailMessage = {
To: [{ Address: "user@example.com" }],
Subject: "Welcome to SuavePlan",
};
describe("assertMail", () => {
it("resolves when message found on first poll", {
timeout: 30000,
}, async () => {
const fetchFn = makeFetch([{ ok: true, messages: [matchingMessage] }]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 5000,
pollIntervalMs: 10,
}
)
).resolves.toBeUndefined();
});
it("resolves when message found on second poll", {
timeout: 30000,
}, async () => {
const fetchFn = makeFetch([
{ ok: true, messages: [] },
{ ok: true, messages: [matchingMessage] },
]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 5000,
pollIntervalMs: 10,
}
)
).resolves.toBeUndefined();
});
it("throws timeout error when no message found", {
timeout: 30000,
}, async () => {
const fetchFn = makeFetch([{ ok: true, messages: [] }]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 50,
pollIntervalMs: 10,
}
)
).rejects.toThrow(
/assertMail.*to="user@example.com".*subject="Welcome"/
);
});
it("throws when API returns non-200 response", {
timeout: 30000,
}, async () => {
const fetchFn = makeFetch([{ ok: false, status: 503, messages: [] }]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 5000,
pollIntervalMs: 10,
}
)
).rejects.toThrow("Mailpit API returned 503");
});
it("matches subject as substring", { timeout: 30000 }, async () => {
const fetchFn = makeFetch([
{
ok: true,
messages: [
{
To: [{ Address: "user@example.com" }],
Subject: "Welcome to SuavePlan — Get Started",
},
],
},
]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome to" },
{
fetchFn,
timeoutMs: 5000,
pollIntervalMs: 10,
}
)
).resolves.toBeUndefined();
});
it("does not match when address differs", { timeout: 30000 }, async () => {
const fetchFn = makeFetch([
{
ok: true,
messages: [
{
To: [{ Address: "other@example.com" }],
Subject: "Welcome",
},
],
},
]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 50,
pollIntervalMs: 10,
}
)
).rejects.toThrow(/assertMail/);
});
it("uses default options when called without options arg", {
timeout: 30000,
}, async () => {
// Temporarily override globalThis.fetch to exercise the defaults path
const original = globalThis.fetch;
let callCount = 0;
globalThis.fetch = async (_url: string | URL | Request) => {
callCount++;
return {
ok: true,
status: 200,
json: async () => ({
messages: [matchingMessage],
}),
} as unknown as Response;
};
try {
// Call without 3rd arg — exercises options={} default, then destructuring defaults
await assertMail("http://localhost:8025", {
to: "user@example.com",
subject: "Welcome",
});
expect(callCount).toBeGreaterThan(0);
} finally {
globalThis.fetch = original;
}
});
it("throws TestEnvironmentError when API returns malformed body", {
timeout: 30000,
}, async () => {
const fetchFn = async (_u: string | URL | Request): Promise<Response> =>
({
ok: true,
status: 200,
json: async () => ({ messages: [{ wrong: "shape" }] }),
}) as unknown as Response;
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{ fetchFn, timeoutMs: 5000, pollIntervalMs: 10 }
)
).rejects.toBeInstanceOf(TestEnvironmentError);
});
it("non-2xx response throws TestEnvironmentError (instance)", {
timeout: 30000,
}, async () => {
const fetchFn = async (_u: string | URL | Request): Promise<Response> =>
({
ok: false,
status: 503,
json: async () => ({}),
}) as unknown as Response;
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{ fetchFn, timeoutMs: 5000, pollIntervalMs: 10 }
)
).rejects.toBeInstanceOf(TestEnvironmentError);
});
it("timeout throws TestEnvironmentError (instance)", {
timeout: 30000,
}, async () => {
const fetchFn = async (_u: string | URL | Request): Promise<Response> =>
({
ok: true,
status: 200,
json: async () => ({ messages: [] }),
}) as unknown as Response;
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{ fetchFn, timeoutMs: 50, pollIntervalMs: 10 }
)
).rejects.toBeInstanceOf(TestEnvironmentError);
});
it("handles empty messages array without crashing", {
timeout: 30000,
}, async () => {
const fetchFn = makeFetch([{ ok: true }]);
await expect(
assertMail(
"http://localhost:8025",
{ to: "user@example.com", subject: "Welcome" },
{
fetchFn,
timeoutMs: 50,
pollIntervalMs: 10,
}
)
).rejects.toThrow(/assertMail/);
});
});

View File

@@ -0,0 +1,97 @@
/**
* assertMail — polling assertion helper for Mailpit REST API.
*
* @module
*/
import { TestEnvironmentError } from "@suaveplan/testing-integration";
import z from "zod";
import {
ASSERT_MAIL_POLL_INTERVAL_MS,
ASSERT_MAIL_TIMEOUT_MS,
} from "../constants/index.js";
/** Zod schema for a single Mailpit message envelope (only the fields we read). */
const mailpitMessageSchema = z.object({
To: z.array(z.object({ Address: z.string() })),
Subject: z.string(),
});
/** Zod schema for the `GET /api/v1/messages` response body shape. */
const mailpitListResponseSchema = z.object({
messages: z.array(mailpitMessageSchema).optional(),
});
export interface AssertMailCriteria {
/** Recipient address to match in the "To" field (exact match). */
to: string;
/** Subject substring to match. */
subject: string;
}
export interface AssertMailOptions {
/** Total time in milliseconds to wait for a matching message. Default: 10_000. */
timeoutMs?: number;
/** Polling interval. Default: 500. */
pollIntervalMs?: number;
/** Injectable fetch for unit tests. Defaults to globalThis.fetch. */
fetchFn?: typeof fetch;
}
/**
* Polls the Mailpit REST API until a message matching the criteria is found,
* or throws a {@link TestEnvironmentError} with details if `timeoutMs` elapses
* or if the API returns an unexpected payload shape.
*
* The response shape is validated by a Zod schema (`{ messages?: Array<{ To:
* Array<{ Address: string }>, Subject: string }> }`). A schema mismatch raises
* `TestEnvironmentError` with the Zod issue list embedded in the message.
*
* @param apiUrl - The Mailpit API base URL (e.g. `http://localhost:8025`).
* @param criteria - Matching criteria: `to` (exact address) and `subject` (substring).
* @param options - Optional timeout, poll interval, and fetch override.
* @throws {TestEnvironmentError} When no matching message is found within the
* timeout, when the API returns a non-2xx status, or when the response body
* fails Zod validation.
*/
export async function assertMail(
apiUrl: string,
criteria: AssertMailCriteria,
options: AssertMailOptions = {}
): Promise<void> {
const {
timeoutMs = /* istanbul ignore next */ ASSERT_MAIL_TIMEOUT_MS,
pollIntervalMs = /* istanbul ignore next */ ASSERT_MAIL_POLL_INTERVAL_MS,
fetchFn = /* istanbul ignore next */ globalThis.fetch,
} = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetchFn(`${apiUrl}/api/v1/messages`);
if (!res.ok) {
throw new TestEnvironmentError(
`Mailpit API returned ${res.status}`
);
}
const raw = await res.json();
const parsed = mailpitListResponseSchema.safeParse(raw);
if (!parsed.success) {
throw new TestEnvironmentError(
`assertMail: unexpected Mailpit response shape — ${JSON.stringify(
parsed.error.issues
)}`
);
}
const messages = parsed.data.messages ?? /* istanbul ignore next */ [];
const found = messages.some(
(m) =>
m.To.some((t) => t.Address === criteria.to) &&
m.Subject.includes(criteria.subject)
);
if (found) return;
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new TestEnvironmentError(
`assertMail: no message matching to="${criteria.to}" subject="${criteria.subject}" ` +
`found within ${timeoutMs}ms`
);
}

View File

@@ -0,0 +1,2 @@
export type { AssertMailCriteria, AssertMailOptions } from "./assert-mail.js";
export { assertMail } from "./assert-mail.js";

View File

@@ -0,0 +1,11 @@
export * from "./constants/index.js";
export { MailpitFixture } from "./fixture/mailpit-fixture.js";
export type {
MailpitConfig,
MailpitFixtureOptions,
} from "./fixture/mailpit-fixture.types.js";
export type {
AssertMailCriteria,
AssertMailOptions,
} from "./helpers/assert-mail.js";
export { assertMail } from "./helpers/assert-mail.js";

View File

@@ -0,0 +1,23 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto; File?: unknown };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
// testcontainers/undici requires File global in Node environments
if (g.File === undefined) {
g.File = class File extends Blob {
readonly name: string;
readonly lastModified: number;
constructor(
fileBits: BlobPart[],
fileName: string,
options?: FilePropertyBag
) {
super(fileBits, options);
this.name = fileName;
this.lastModified = options?.lastModified ?? Date.now();
}
};
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
external: [
"testcontainers",
"@suaveplan/testing-integration",
"node:net",
"node:crypto",
],
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
},
},
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
rollupTypes: false,
}),
],
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});