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
.nameis 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_URLis 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.smtpUrlandresult.apiUrlreflect mapped ports
Scenario: stop calls container.stop()
- WHEN
stop()followsstart()in container mode - THEN the container's
stop()is invoked exactly once
Scenario: factory falls back to dynamic GenericContainer import
- WHEN no
containerFactoryis 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 totrue
Scenario: returns false on ok: false
- WHEN the injected fetch returns
{ ok: false } - THEN
healthCheck()resolves tofalse
Scenario: returns false when fetch throws
- WHEN the injected fetch throws
- THEN
healthCheck()resolves tofalse
Scenario: returns false when no container
- WHEN
healthCheck()is called beforestart() - 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
assertMailresolves without further polling
Scenario: resolves on subsequent poll
- WHEN the first poll returns no match but the second does
- THEN
assertMailresolves
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 withoutoptions - THEN the call uses
globalThis.fetchand 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.jsonis inspected - THEN
dependenciesdoes not contain either symbol
Verified by: src/fixture/mailpit-fixture.test.ts::"name property returns mailpit"