feat: backend service
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package testenv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcnetwork "github.com/testcontainers/testcontainers-go/network"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
// Mailpit holds an axllent/mailpit testcontainer that captures
|
||||
// outbound SMTP from backend. The HTTP API is exposed for mail
|
||||
// inspection from tests.
|
||||
type Mailpit struct {
|
||||
container testcontainers.Container
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
APIBase string
|
||||
}
|
||||
|
||||
// StartMailpit starts an axllent/mailpit container attached to network.
|
||||
func StartMailpit(t *testing.T, network string) *Mailpit {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "axllent/mailpit:latest",
|
||||
ExposedPorts: []string{"1025/tcp", "8025/tcp"},
|
||||
WaitingFor: wait.ForHTTP("/api/v1/info").WithPort("8025/tcp"),
|
||||
}
|
||||
gcr := &testcontainers.GenericContainerRequest{ContainerRequest: req}
|
||||
if network != "" {
|
||||
netOpt := tcnetwork.WithNetwork([]string{"mailpit"}, &testcontainers.DockerNetwork{Name: network})
|
||||
_ = netOpt.Customize(gcr)
|
||||
}
|
||||
|
||||
gcr.Started = true
|
||||
container, err := testcontainers.GenericContainer(ctx, *gcr)
|
||||
if err != nil {
|
||||
t.Skipf("mailpit container unavailable: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := testcontainers.TerminateContainer(container); err != nil {
|
||||
t.Logf("terminate mailpit: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
host, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mailpit host: %v", err)
|
||||
}
|
||||
smtpPort, err := container.MappedPort(ctx, "1025/tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("mailpit smtp port: %v", err)
|
||||
}
|
||||
apiPort, err := container.MappedPort(ctx, "8025/tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("mailpit api port: %v", err)
|
||||
}
|
||||
return &Mailpit{
|
||||
container: container,
|
||||
SMTPHost: host,
|
||||
SMTPPort: int(smtpPort.Num()),
|
||||
APIBase: fmt.Sprintf("http://%s:%d", host, apiPort.Num()),
|
||||
}
|
||||
}
|
||||
|
||||
// Message is a single mailpit message summary.
|
||||
type Message struct {
|
||||
ID string `json:"ID"`
|
||||
From MessageAddress `json:"From"`
|
||||
To []MessageAddress `json:"To"`
|
||||
Subject string `json:"Subject"`
|
||||
Snippet string `json:"Snippet"`
|
||||
}
|
||||
|
||||
// MessageAddress is one address in From/To.
|
||||
type MessageAddress struct {
|
||||
Address string `json:"Address"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type messagesResponse struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// MessageBody fetches the rendered body (text) of message id.
|
||||
func (m *Mailpit) MessageBody(ctx context.Context, id string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.APIBase+"/api/v1/message/"+url.PathEscape(id), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("mailpit message %s: status %d", id, resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Text string `json:"Text"`
|
||||
HTML string `json:"HTML"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if body.Text != "" {
|
||||
return body.Text, nil
|
||||
}
|
||||
return body.HTML, nil
|
||||
}
|
||||
|
||||
// Search returns messages matching the mailpit search expression. See
|
||||
// https://mailpit.axllent.org/docs/usage/search-filters/.
|
||||
func (m *Mailpit) Search(ctx context.Context, query string) ([]Message, error) {
|
||||
u := m.APIBase + "/api/v1/search?query=" + url.QueryEscape(query)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("mailpit search: status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out messagesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Messages, nil
|
||||
}
|
||||
|
||||
// WaitForMessage polls Search until a message matching query is seen
|
||||
// or the deadline elapses.
|
||||
func (m *Mailpit) WaitForMessage(ctx context.Context, query string, timeout time.Duration) (Message, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
msgs, err := m.Search(ctx, query)
|
||||
if err == nil && len(msgs) > 0 {
|
||||
return msgs[0], nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("no messages match %q", query)
|
||||
}
|
||||
return Message{}, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Message{}, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAll clears the mailpit inbox. Useful between phases of a test.
|
||||
func (m *Mailpit) DeleteAll(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, m.APIBase+"/api/v1/messages", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("mailpit delete: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContainsLine reports whether body contains a line that begins with
|
||||
// prefix; helpful for extracting login codes from the text body.
|
||||
func ContainsLine(body, prefix string) bool {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user