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: "
Turn 54 is ready.
", }, 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": "Turn 54 is ready.
", "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": "Turn 54 is ready.
", "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() }