feat: mail service
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOperatorStoreListFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
filters listdeliveries.Filters
|
||||
wantIDs []common.DeliveryID
|
||||
}
|
||||
|
||||
cases := []testCase{
|
||||
{
|
||||
name: "recipient",
|
||||
filters: listdeliveries.Filters{Recipient: common.Email("recipient-filter@example.com")},
|
||||
wantIDs: []common.DeliveryID{"delivery-recipient"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
filters: listdeliveries.Filters{Status: deliverydomain.StatusSuppressed},
|
||||
wantIDs: []common.DeliveryID{"delivery-status"},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
filters: listdeliveries.Filters{Source: deliverydomain.SourceOperatorResend},
|
||||
wantIDs: []common.DeliveryID{"delivery-source"},
|
||||
},
|
||||
{
|
||||
name: "template",
|
||||
filters: listdeliveries.Filters{TemplateID: common.TemplateID("template.filter")},
|
||||
wantIDs: []common.DeliveryID{"delivery-template"},
|
||||
},
|
||||
{
|
||||
name: "idempotency",
|
||||
filters: listdeliveries.Filters{IdempotencyKey: common.IdempotencyKey("idempotency-filter")},
|
||||
wantIDs: []common.DeliveryID{"delivery-idempotency"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
seedOperatorFilterDataset(t, client)
|
||||
|
||||
result, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 10,
|
||||
Filters: tt.filters,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantIDs, deliveryIDs(result.Items))
|
||||
require.Nil(t, result.NextCursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorStoreListCursorPaginationUsesCreatedAtDescDeliveryIDDesc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
|
||||
createdAt := time.Unix(1_775_122_500, 0).UTC()
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-a", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-a"), deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-c", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-c"), deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-b", createdAt, deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-b"), deliverydomain.StatusSent))
|
||||
|
||||
firstPage, err := store.List(context.Background(), listdeliveries.Input{Limit: 2})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-c", "delivery-b"}, deliveryIDs(firstPage.Items))
|
||||
require.NotNil(t, firstPage.NextCursor)
|
||||
|
||||
secondPage, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 2,
|
||||
Cursor: firstPage.NextCursor,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-a"}, deliveryIDs(secondPage.Items))
|
||||
require.Nil(t, secondPage.NextCursor)
|
||||
}
|
||||
|
||||
func TestOperatorStoreListMergesIdempotencyAcrossSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
sharedKey := common.IdempotencyKey("shared-idempotency")
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-auth", time.Unix(1_775_122_100, 0).UTC(), deliverydomain.SourceAuthSession, sharedKey, deliverydomain.StatusSuppressed))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-notification", time.Unix(1_775_122_200, 0).UTC(), deliverydomain.SourceNotification, sharedKey, deliverydomain.StatusSent))
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-resend", time.Unix(1_775_122_300, 0).UTC(), deliverydomain.SourceOperatorResend, sharedKey, deliverydomain.StatusSent))
|
||||
|
||||
result, err := store.List(context.Background(), listdeliveries.Input{
|
||||
Limit: 10,
|
||||
Filters: listdeliveries.Filters{
|
||||
IdempotencyKey: sharedKey,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.DeliveryID{"delivery-resend", "delivery-notification", "delivery-auth"}, deliveryIDs(result.Items))
|
||||
}
|
||||
|
||||
func TestOperatorStoreGetDeadLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
record := buildStoredDelivery("delivery-dead-letter", time.Unix(1_775_122_400, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-dead-letter"), deliverydomain.StatusDeadLetter)
|
||||
seedDeliveryRecord(t, client, record)
|
||||
|
||||
entry := validDeadLetterEntry(t, record.DeliveryID)
|
||||
payload, err := MarshalDeadLetter(entry)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.DeadLetter(record.DeliveryID), payload, DeadLetterTTL).Err())
|
||||
|
||||
got, found, err := store.GetDeadLetter(context.Background(), record.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, entry, got)
|
||||
}
|
||||
|
||||
func TestOperatorStoreListAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
record := buildStoredDelivery("delivery-attempts", time.Unix(1_775_122_410, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-attempts"), deliverydomain.StatusFailed)
|
||||
record.AttemptCount = 2
|
||||
failedAt := record.UpdatedAt
|
||||
record.FailedAt = &failedAt
|
||||
require.NoError(t, record.Validate())
|
||||
seedDeliveryRecord(t, client, record)
|
||||
|
||||
firstAttempt := validTerminalAttempt(t, record.DeliveryID)
|
||||
firstAttempt.AttemptNo = 1
|
||||
secondAttempt := validTerminalAttempt(t, record.DeliveryID)
|
||||
secondAttempt.AttemptNo = 2
|
||||
secondAttempt.Status = attempt.StatusProviderRejected
|
||||
payload, err := MarshalAttempt(firstAttempt)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 1), payload, AttemptTTL).Err())
|
||||
payload, err = MarshalAttempt(secondAttempt)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 2), payload, AttemptTTL).Err())
|
||||
|
||||
got, err := store.ListAttempts(context.Background(), record.DeliveryID, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []attempt.Attempt{firstAttempt, secondAttempt}, got)
|
||||
}
|
||||
|
||||
func TestOperatorStoreCreateResendAtomicallyCreatesCloneState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, client := newOperatorStoreForTest(t)
|
||||
|
||||
createdAt := time.Unix(1_775_122_600, 0).UTC()
|
||||
clone := buildStoredDelivery("delivery-clone", createdAt, deliverydomain.SourceOperatorResend, common.IdempotencyKey("operator:resend:delivery-parent"), deliverydomain.StatusQueued)
|
||||
clone.ResendParentDeliveryID = common.DeliveryID("delivery-parent")
|
||||
clone.AttemptCount = 1
|
||||
require.NoError(t, clone.Validate())
|
||||
|
||||
firstAttempt := validScheduledAttempt(t, clone.DeliveryID)
|
||||
firstAttempt.AttemptNo = 1
|
||||
firstAttempt.ScheduledFor = createdAt
|
||||
require.NoError(t, firstAttempt.Validate())
|
||||
|
||||
deliveryPayload := validDeliveryPayload(t, clone.DeliveryID)
|
||||
input := resenddelivery.CreateResendInput{
|
||||
Delivery: clone,
|
||||
FirstAttempt: firstAttempt,
|
||||
DeliveryPayload: &deliveryPayload,
|
||||
}
|
||||
|
||||
require.NoError(t, store.CreateResend(context.Background(), input))
|
||||
|
||||
storedDelivery, found, err := store.GetDelivery(context.Background(), clone.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, clone, storedDelivery)
|
||||
|
||||
storedPayload, found, err := store.GetDeliveryPayload(context.Background(), clone.DeliveryID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, deliveryPayload, storedPayload)
|
||||
|
||||
attemptPayload, err := client.Get(context.Background(), Keyspace{}.Attempt(clone.DeliveryID, 1)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(attemptPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstAttempt, decodedAttempt)
|
||||
|
||||
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{clone.DeliveryID.String()}, scheduledMembers)
|
||||
|
||||
indexMembers, err := client.ZRange(context.Background(), Keyspace{}.IdempotencyIndex(clone.Source, clone.IdempotencyKey), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{clone.DeliveryID.String()}, indexMembers)
|
||||
|
||||
_, err = client.Get(context.Background(), Keyspace{}.Idempotency(clone.Source, clone.IdempotencyKey)).Bytes()
|
||||
require.ErrorIs(t, err, redis.Nil)
|
||||
}
|
||||
|
||||
func newOperatorStoreForTest(t *testing.T) (*OperatorStore, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
store, err := NewOperatorStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, client
|
||||
}
|
||||
|
||||
func seedOperatorFilterDataset(t *testing.T, client *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-recipient", time.Unix(1_775_122_001, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-recipient"), deliverydomain.StatusSent)
|
||||
record.Envelope.To = []common.Email{common.Email("recipient-filter@example.com")}
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-status", time.Unix(1_775_122_002, 0).UTC(), deliverydomain.SourceAuthSession, common.IdempotencyKey("authsession:delivery-status"), deliverydomain.StatusSuppressed)
|
||||
record.SentAt = nil
|
||||
suppressedAt := record.UpdatedAt
|
||||
record.SuppressedAt = &suppressedAt
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-source", time.Unix(1_775_122_003, 0).UTC(), deliverydomain.SourceOperatorResend, common.IdempotencyKey("operator:resend:delivery-source"), deliverydomain.StatusSent))
|
||||
|
||||
seedDeliveryRecord(t, client, func() deliverydomain.Delivery {
|
||||
record := buildStoredDelivery("delivery-template", time.Unix(1_775_122_004, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("notification:delivery-template"), deliverydomain.StatusSent)
|
||||
record.TemplateID = common.TemplateID("template.filter")
|
||||
record.PayloadMode = deliverydomain.PayloadModeTemplate
|
||||
record.Locale = common.Locale("en")
|
||||
record.TemplateVariables = map[string]any{"name": "Pilot"}
|
||||
require.NoError(t, record.Validate())
|
||||
return record
|
||||
}())
|
||||
|
||||
seedDeliveryRecord(t, client, buildStoredDelivery("delivery-idempotency", time.Unix(1_775_122_005, 0).UTC(), deliverydomain.SourceNotification, common.IdempotencyKey("idempotency-filter"), deliverydomain.StatusSent))
|
||||
}
|
||||
|
||||
func seedDeliveryRecord(t *testing.T, client *redis.Client, record deliverydomain.Delivery) {
|
||||
t.Helper()
|
||||
|
||||
keyspace := Keyspace{}
|
||||
payload, err := MarshalDelivery(record)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Set(context.Background(), keyspace.Delivery(record.DeliveryID), payload, DeliveryTTL).Err())
|
||||
|
||||
score := CreatedAtScore(record.CreatedAt)
|
||||
for _, indexKey := range keyspace.DeliveryIndexKeys(record) {
|
||||
require.NoError(t, client.ZAdd(context.Background(), indexKey, redis.Z{
|
||||
Score: score,
|
||||
Member: record.DeliveryID.String(),
|
||||
}).Err())
|
||||
}
|
||||
}
|
||||
|
||||
func buildStoredDelivery(
|
||||
deliveryID string,
|
||||
createdAt time.Time,
|
||||
source deliverydomain.Source,
|
||||
idempotencyKey common.IdempotencyKey,
|
||||
status deliverydomain.Status,
|
||||
) deliverydomain.Delivery {
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(deliveryID),
|
||||
Source: source,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
Content: deliverydomain.Content{
|
||||
Subject: "Test subject",
|
||||
TextBody: "Test body",
|
||||
},
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
switch status {
|
||||
case deliverydomain.StatusSent:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusProviderAccepted
|
||||
sentAt := updatedAt
|
||||
record.SentAt = &sentAt
|
||||
case deliverydomain.StatusSuppressed:
|
||||
suppressedAt := updatedAt
|
||||
record.SuppressedAt = &suppressedAt
|
||||
case deliverydomain.StatusFailed:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusProviderRejected
|
||||
failedAt := updatedAt
|
||||
record.FailedAt = &failedAt
|
||||
case deliverydomain.StatusDeadLetter:
|
||||
record.AttemptCount = 1
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
deadLetteredAt := updatedAt
|
||||
record.DeadLetteredAt = &deadLetteredAt
|
||||
default:
|
||||
record.AttemptCount = 1
|
||||
}
|
||||
if source == deliverydomain.SourceOperatorResend {
|
||||
record.ResendParentDeliveryID = common.DeliveryID("parent-" + deliveryID)
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func deliveryIDs(records []deliverydomain.Delivery) []common.DeliveryID {
|
||||
result := make([]common.DeliveryID, len(records))
|
||||
for index, record := range records {
|
||||
result[index] = record.DeliveryID
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user