feat: mail service
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeCommandSuccessRendered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, Command{
|
||||
DeliveryID: common.DeliveryID("mail-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:mail-123"),
|
||||
RequestedAt: mustUnixMilli(1_775_121_700_000),
|
||||
RequestID: "req-123",
|
||||
TraceID: "trace-123",
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
Cc: []common.Email{},
|
||||
Bcc: []common.Email{},
|
||||
ReplyTo: []common.Email{"noreply@example.com"},
|
||||
},
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
SizeBytes: 6,
|
||||
},
|
||||
},
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
HTMLBody: "<p>Turn 54 is ready.</p>",
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeCommandSuccessTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validTemplateFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), command.TemplateID)
|
||||
require.Equal(t, common.Locale("fr-FR"), command.Locale)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
}, command.Variables)
|
||||
require.Empty(t, command.Subject)
|
||||
require.Empty(t, command.TextBody)
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing required field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
delete(fields, fieldDeliveryID)
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "missing required fields: delivery_id",
|
||||
},
|
||||
{
|
||||
name: "unsupported field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields["extra"] = "value"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unsupported fields: extra",
|
||||
},
|
||||
{
|
||||
name: "non string field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldDeliveryID] = 42
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "delivery_id" must be a string`,
|
||||
},
|
||||
{
|
||||
name: "invalid requested at",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldRequestedAtMS] = "not-a-timestamp"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "requested_at_ms" must be a base-10 Unix milliseconds string`,
|
||||
},
|
||||
{
|
||||
name: "unsupported source",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldSource] = "operator_resend"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream command source "operator_resend" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "unsupported payload mode",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadMode] = "unknown"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "payload_mode" value "unknown" is unsupported`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "payload must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = `[]`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json",
|
||||
},
|
||||
{
|
||||
name: "rendered payload unknown field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
"template_id": "game.turn_ready",
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unknown field",
|
||||
},
|
||||
{
|
||||
name: "trailing json input",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = validRenderedPayloadJSON(t) + `{}`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "empty recipients",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at least one recipient",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "english",
|
||||
"variables": map[string]any{},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "payload_json.locale:",
|
||||
},
|
||||
{
|
||||
name: "variables must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": []string{"not", "object"},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json.variables",
|
||||
},
|
||||
{
|
||||
name: "invalid attachment base64",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "!@#",
|
||||
},
|
||||
},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "content_base64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "too many attachments",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
attachments := make([]map[string]any, 0, MaxAttachments+1)
|
||||
for index := 0; index < MaxAttachments+1; index++ {
|
||||
attachments = append(attachments, map[string]any{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("a")),
|
||||
})
|
||||
}
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": attachments,
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at most 5 entries",
|
||||
},
|
||||
{
|
||||
name: "encoded attachment payload limit exceeded",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": oversizedBase64(),
|
||||
}},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "encoded attachment payload must not exceed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFingerprintIgnoresTracingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldRequestID] = "req-456"
|
||||
secondFields[fieldTraceID] = "trace-456"
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestCommandFingerprintChangesForBusinessFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Different subject",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{{"filename": "report.txt", "content_type": "text/plain", "content_base64": base64.StdEncoding.EncodeToString([]byte("report"))}},
|
||||
})
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func validRenderedFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-123",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "rendered",
|
||||
fieldIdempotency: "notification:mail-123",
|
||||
fieldRequestedAtMS: "1775121700000",
|
||||
fieldRequestID: "req-123",
|
||||
fieldTraceID: "trace-123",
|
||||
fieldPayloadJSON: validRenderedPayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validTemplateFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-124",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "template",
|
||||
fieldIdempotency: "notification:mail-124",
|
||||
fieldRequestedAtMS: "1775121700001",
|
||||
fieldPayloadJSON: validTemplatePayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validRenderedPayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validTemplatePayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
func mustJSONString(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func oversizedBase64() string {
|
||||
return string(bytesOf('A', MaxEncodedAttachmentPayloadBytes+4))
|
||||
}
|
||||
|
||||
func bytesOf(value byte, size int) []byte {
|
||||
result := make([]byte, size)
|
||||
for index := range result {
|
||||
result[index] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mustUnixMilli(value int64) time.Time {
|
||||
return time.UnixMilli(value).UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user