Files
2026-05-27 23:29:53 +01:00

8.2 KiB

testing-integration-mailpit

Purpose

Mailpit ServiceFixture plus assertMail() helper for integration tests. Provides env-bridge passthrough mode (MAILPIT_SMTP_URL + MAILPIT_API_URL), API-based health checking, injectable fetch, partial subject matching, and exact address matching. Enables SMTP and email-flow integration tests without mocks. Depends on @suaveplan/testing-integration and testcontainers.

Requirements

Requirement: MailpitFixture implements ServiceFixture

The package SHALL export MailpitFixture with name === "mailpit". MailpitConfig SHALL be { smtpUrl: string; apiUrl: string }.

Scenario: name property is "mailpit"

  • WHEN an instance's .name is read
  • THEN the value is the literal "mailpit"

Verified by: src/fixture/mailpit-fixture.test.ts::"name property returns mailpit"


Requirement: Env-bridge passthrough mode (2 vars, both required)

When BOTH MAILPIT_SMTP_URL AND MAILPIT_API_URL are set and non-empty, start() MUST return them directly. A partial set falls through to container mode. In passthrough mode stop() is a no-op.

Scenario: returns config from both env vars

  • WHEN both env vars are set
  • THEN start() returns { smtpUrl, apiUrl } without invoking testcontainers

Scenario: stop is a no-op in passthrough mode

  • WHEN stop() follows passthrough start
  • THEN no container is touched

Scenario: partial passthrough falls through to container mode

  • WHEN only MAILPIT_SMTP_URL is set
  • THEN start() boots a container

Verified by: src/fixture/mailpit-fixture.test.ts::"passthrough mode: returns config from both env vars" AND src/fixture/mailpit-fixture.test.ts::"passthrough mode: stop is a no-op" AND src/fixture/mailpit-fixture.test.ts::"partial passthrough (only SMTP set) falls through to container mode"


Requirement: Container mode

When env vars are partial or unset, start() MUST start a Mailpit container exposing MAILPIT_SMTP_PORT and MAILPIT_API_PORT, return { smtpUrl: "smtp://localhost:<smtpPort>", apiUrl: "http://localhost:<apiPort>" }.

Scenario: container mode happy path returns correct URLs

  • WHEN start() completes container boot
  • THEN result.smtpUrl and result.apiUrl reflect mapped ports

Scenario: stop calls container.stop()

  • WHEN stop() follows start() in container mode
  • THEN the container's stop() is invoked exactly once

Scenario: factory falls back to dynamic GenericContainer import

  • WHEN no containerFactory is injected
  • THEN the fixture dynamically imports testcontainers and uses GenericContainer

Verified by: src/fixture/mailpit-fixture.test.ts::"container mode happy path: returns correct URLs" AND src/fixture/mailpit-fixture.test.ts::"container mode: stop calls container.stop()" AND src/fixture/mailpit-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"


Requirement: API health probe

In container mode, healthCheck() MUST GET <apiUrl>/api/v1/messages via the injected fetchFn (default globalThis.fetch) and return res.ok. Network errors MUST return false. Without a started container, healthCheck() MUST return false.

Scenario: returns true via injected fetchFn

  • WHEN the injected fetch returns { ok: true }
  • THEN healthCheck() resolves to true

Scenario: returns false on ok: false

  • WHEN the injected fetch returns { ok: false }
  • THEN healthCheck() resolves to false

Scenario: returns false when fetch throws

  • WHEN the injected fetch throws
  • THEN healthCheck() resolves to false

Scenario: returns false when no container

  • WHEN healthCheck() is called before start()
  • THEN the result is false

Verified by: src/fixture/mailpit-fixture.test.ts::"healthCheck returns true via injected fetchFn" AND src/fixture/mailpit-fixture.test.ts::"healthCheck returns false via injected fetchFn (ok: false)" AND src/fixture/mailpit-fixture.test.ts::"healthCheck returns false when fetch throws" AND src/fixture/mailpit-fixture.test.ts::"healthCheck returns false when no container"


Requirement: stop() is idempotent

stop() MUST be safe to call when no container has been started.

Scenario: stop without start is a no-op

  • WHEN stop() is called on a freshly-constructed fixture
  • THEN no error is thrown

Verified by: src/fixture/mailpit-fixture.test.ts::"stop when no container is a no-op"


Requirement: assertMail polls Mailpit for a matching message

The package SHALL export assertMail(apiUrl, criteria, options?). The function MUST poll GET <apiUrl>/api/v1/messages at pollIntervalMs (default ASSERT_MAIL_POLL_INTERVAL_MS) until Date.now() >= deadline = Date.now() + timeoutMs (default ASSERT_MAIL_TIMEOUT_MS). A message matches when ANY entry in m.To has Address === criteria.to (exact) AND m.Subject contains criteria.subject (substring). Resolution returns void. The fetchFn MUST be injectable.

Scenario: resolves on first poll when message present

  • WHEN the first poll returns a matching message
  • THEN assertMail resolves without further polling

Scenario: resolves on subsequent poll

  • WHEN the first poll returns no match but the second does
  • THEN assertMail resolves

Scenario: subject matches as substring

  • WHEN the criteria subject is a substring of the actual subject
  • THEN the message matches

Scenario: address must match exactly

  • WHEN the criteria address differs from any actual address
  • THEN the message does NOT match

Scenario: defaults are used when options arg omitted

  • WHEN assertMail(url, criteria) is called without options
  • THEN the call uses globalThis.fetch and the default timeout/poll interval

Verified by: src/helpers/assert-mail.test.ts::"resolves when message found on first poll" AND src/helpers/assert-mail.test.ts::"resolves when message found on second poll" AND src/helpers/assert-mail.test.ts::"matches subject as substring" AND src/helpers/assert-mail.test.ts::"does not match when address differs" AND src/helpers/assert-mail.test.ts::"uses default options when called without options arg" AND src/helpers/assert-mail.test.ts::"handles empty messages array without crashing"


Requirement: assertMail validates response shape via Zod

The function MUST validate every Mailpit response with a Zod schema ({ messages?: Array<{ To: Array<{ Address: string }>, Subject: string }> }). Schema mismatch, non-2xx HTTP status, and timeout MUST all throw TestEnvironmentError (the same error class re-exported from @suaveplan/testing-integration).

Scenario: timeout throws TestEnvironmentError

  • WHEN no matching message arrives within timeoutMs
  • THEN the rejection is an instance of TestEnvironmentError

Scenario: non-2xx response throws TestEnvironmentError

  • WHEN the API returns 503
  • THEN the rejection is an instance of TestEnvironmentError

Scenario: malformed response throws TestEnvironmentError

  • WHEN the response body fails Zod validation
  • THEN the rejection is an instance of TestEnvironmentError

Verified by: src/helpers/assert-mail.test.ts::"throws timeout error when no message found" AND src/helpers/assert-mail.test.ts::"throws when API returns non-200 response" AND src/helpers/assert-mail.test.ts::"throws TestEnvironmentError when API returns malformed body" AND src/helpers/assert-mail.test.ts::"non-2xx response throws TestEnvironmentError (instance)" AND src/helpers/assert-mail.test.ts::"timeout throws TestEnvironmentError (instance)"


Requirement: testing-integration is a peer dependency

The package's package.json MUST list @suaveplan/testing-integration and testcontainers in peerDependencies, NOT in dependencies. zod MAY be in dependencies (used by assertMail).

Scenario: variant does not bundle the core or testcontainers

  • WHEN the published package.json is inspected
  • THEN dependencies does not contain either symbol

Verified by: src/fixture/mailpit-fixture.test.ts::"name property returns mailpit"