Files
galaxy-game/integration/internal/harness/mail_stub.go
T
2026-04-09 15:27:14 +02:00

183 lines
4.1 KiB
Go

package harness
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
const mailStubPath = "/api/v1/internal/login-code-deliveries"
// LoginCodeDelivery stores one mail-delivery request received by the external
// mail stub.
type LoginCodeDelivery struct {
// Email identifies the target e-mail address requested by authsession.
Email string
// Code stores the cleartext login code requested by authsession.
Code string
}
// MailBehavior overrides one external mail-stub response.
type MailBehavior struct {
// Delay waits before the stub writes its response.
Delay time.Duration
// StatusCode overrides the HTTP status returned by the stub. Zero keeps the
// default `200 OK`.
StatusCode int
// RawBody overrides the exact response body returned by the stub. Empty
// value keeps the default JSON payload for the chosen status.
RawBody string
}
// MailStub provides one stateful external HTTP mail-service stub.
type MailStub struct {
server *httptest.Server
mu sync.Mutex
deliveries []LoginCodeDelivery
behavior MailBehavior
}
// NewMailStub starts one stateful external HTTP mail-service stub.
func NewMailStub(t testing.TB) *MailStub {
t.Helper()
stub := &MailStub{}
stub.server = httptest.NewServer(http.HandlerFunc(stub.handle))
t.Cleanup(stub.server.Close)
return stub
}
// BaseURL returns the stub base URL suitable for service runtime wiring.
func (s *MailStub) BaseURL() string {
if s == nil || s.server == nil {
return ""
}
return s.server.URL
}
// SetBehavior replaces the current response behavior used by subsequent
// requests.
func (s *MailStub) SetBehavior(behavior MailBehavior) {
s.mu.Lock()
defer s.mu.Unlock()
s.behavior = behavior
}
// RecordedDeliveries returns a snapshot of all delivery requests received by
// the stub so far.
func (s *MailStub) RecordedDeliveries() []LoginCodeDelivery {
s.mu.Lock()
defer s.mu.Unlock()
cloned := make([]LoginCodeDelivery, len(s.deliveries))
copy(cloned, s.deliveries)
return cloned
}
// Reset clears the recorded deliveries and restores default behavior.
func (s *MailStub) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.deliveries = nil
s.behavior = MailBehavior{}
}
func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != mailStubPath {
http.NotFound(writer, request)
return
}
var payload struct {
Email string `json:"email"`
Code string `json:"code"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.deliveries = append(s.deliveries, LoginCodeDelivery{
Email: payload.Email,
Code: payload.Code,
})
behavior := s.behavior
s.mu.Unlock()
if behavior.Delay > 0 {
timer := time.NewTimer(behavior.Delay)
defer timer.Stop()
select {
case <-request.Context().Done():
return
case <-timer.C:
}
}
statusCode := behavior.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
body := behavior.RawBody
if body == "" {
switch statusCode {
case http.StatusOK:
body = `{"outcome":"sent"}`
default:
body = `{"error":"stubbed mail failure"}`
}
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_, _ = io.WriteString(writer, body)
}
func decodeStrictJSONRequest(request *http.Request, target any) error {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}