Initial Commit
This commit is contained in:
174
openspec/specs/testing-integration-mailpit/spec.md
Normal file
174
openspec/specs/testing-integration-mailpit/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 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<MailpitConfig>
|
||||
|
||||
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"`
|
||||
Reference in New Issue
Block a user