feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -0,0 +1,391 @@
package worker
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
"galaxy/mail/internal/adapters/redisstate"
"galaxy/mail/internal/service/acceptgenericdelivery"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestCommandConsumerAcceptsRenderedCommand(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
messageID := addRenderedCommand(t, fixture.client, "mail-123", "notification:mail-123")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- fixture.consumer.Run(ctx)
}()
require.Eventually(t, func() bool {
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-123")
if err != nil || !found {
return false
}
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
return err == nil && found && entryID == messageID && delivery.DeliveryID == "mail-123"
}, 5*time.Second, 20*time.Millisecond)
cancel()
require.ErrorIs(t, <-done, context.Canceled)
}
func TestCommandConsumerAcceptsTemplateCommand(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
messageID := addTemplateCommand(t, fixture.client, "mail-124", "notification:mail-124")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- fixture.consumer.Run(ctx)
}()
require.Eventually(t, func() bool {
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-124")
if err != nil || !found {
return false
}
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
return err == nil && found && entryID == messageID && delivery.TemplateID == "game.turn_ready"
}, 5*time.Second, 20*time.Millisecond)
cancel()
require.ErrorIs(t, <-done, context.Canceled)
}
func TestCommandConsumerRecordsMalformedCommandAndContinues(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
malformedID := addMalformedRenderedCommand(t, fixture.client, "mail-bad", "notification:mail-bad")
validID := addRenderedCommand(t, fixture.client, "mail-125", "notification:mail-125")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- fixture.consumer.Run(ctx)
}()
require.Eventually(t, func() bool {
_, deliveryFound, deliveryErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-125")
entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), malformedID)
entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream)
return deliveryErr == nil &&
malformedErr == nil &&
offsetErr == nil &&
deliveryFound &&
malformedFound &&
entry.FailureCode == "invalid_payload" &&
offsetFound &&
entryID == validID
}, 5*time.Second, 20*time.Millisecond)
cancel()
require.ErrorIs(t, <-done, context.Canceled)
}
func TestCommandConsumerRestartsFromSavedOffset(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
firstID := addRenderedCommand(t, fixture.client, "mail-126", "notification:mail-126")
firstCtx, firstCancel := context.WithCancel(context.Background())
firstDone := make(chan error, 1)
go func() {
firstDone <- fixture.consumer.Run(firstCtx)
}()
require.Eventually(t, func() bool {
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
return err == nil && found && entryID == firstID
}, 5*time.Second, 20*time.Millisecond)
firstCancel()
require.ErrorIs(t, <-firstDone, context.Canceled)
secondID := addRenderedCommand(t, fixture.client, "mail-127", "notification:mail-127")
secondCtx, secondCancel := context.WithCancel(context.Background())
secondDone := make(chan error, 1)
go func() {
secondDone <- fixture.consumer.Run(secondCtx)
}()
require.Eventually(t, func() bool {
_, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-126")
_, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-127")
entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream)
return firstErr == nil &&
secondErr == nil &&
offsetErr == nil &&
firstFound &&
secondFound &&
offsetFound &&
entryID == secondID
}, 5*time.Second, 20*time.Millisecond)
secondCancel()
require.ErrorIs(t, <-secondDone, context.Canceled)
}
func TestCommandConsumerDoesNotDuplicateAcceptanceAfterOffsetSaveFailure(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
messageID := addRenderedCommand(t, fixture.client, "mail-128", "notification:mail-128")
failingOffsetStore := &scriptedOffsetStore{
saveErrs: []error{errors.New("offset unavailable")},
}
consumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore)
err := consumer.Run(context.Background())
require.Error(t, err)
require.ErrorContains(t, err, "save stream offset")
delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-128")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, "mail-128", delivery.DeliveryID.String())
indexCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, indexCard)
replayConsumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore)
replayCtx, replayCancel := context.WithCancel(context.Background())
replayDone := make(chan error, 1)
go func() {
replayDone <- replayConsumer.Run(replayCtx)
}()
require.Eventually(t, func() bool {
return failingOffsetStore.lastEntryID == messageID
}, 5*time.Second, 20*time.Millisecond)
replayCancel()
require.ErrorIs(t, <-replayDone, context.Canceled)
indexCard, err = fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, indexCard)
scheduleCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.AttemptSchedule()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, scheduleCard)
}
func TestCommandConsumerRecordsIdempotencyConflictAsMalformed(t *testing.T) {
t.Parallel()
fixture := newCommandConsumerFixture(t)
addRenderedCommand(t, fixture.client, "mail-129", "notification:shared")
conflictID := addRenderedCommandWithSubject(t, fixture.client, "mail-130", "notification:shared", "Different subject")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- fixture.consumer.Run(ctx)
}()
require.Eventually(t, func() bool {
_, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-129")
_, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-130")
entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), conflictID)
return firstErr == nil &&
secondErr == nil &&
malformedErr == nil &&
firstFound &&
!secondFound &&
malformedFound &&
entry.FailureCode == "idempotency_conflict"
}, 5*time.Second, 20*time.Millisecond)
cancel()
require.ErrorIs(t, <-done, context.Canceled)
}
type commandConsumerFixture struct {
client *redis.Client
stream string
consumer *CommandConsumer
acceptor *acceptgenericdelivery.Service
acceptanceStore *redisstate.GenericAcceptanceStore
malformedStore *redisstate.MalformedCommandStore
offsetStore *redisstate.StreamOffsetStore
}
func newCommandConsumerFixture(t *testing.T) commandConsumerFixture {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
acceptanceStore, err := redisstate.NewGenericAcceptanceStore(client)
require.NoError(t, err)
now := time.Now().UTC().Truncate(time.Millisecond)
acceptor, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{
Store: acceptanceStore,
Clock: testClock{now: now},
IdempotencyTTL: redisstate.IdempotencyTTL,
})
require.NoError(t, err)
malformedStore, err := redisstate.NewMalformedCommandStore(client)
require.NoError(t, err)
offsetStore, err := redisstate.NewStreamOffsetStore(client)
require.NoError(t, err)
stream := redisstate.Keyspace{}.DeliveryCommands()
consumer := newCommandConsumerForTest(t, client, stream, acceptor, malformedStore, offsetStore)
return commandConsumerFixture{
client: client,
stream: stream,
consumer: consumer,
acceptor: acceptor,
acceptanceStore: acceptanceStore,
malformedStore: malformedStore,
offsetStore: offsetStore,
}
}
func newCommandConsumerForTest(
t *testing.T,
client *redis.Client,
stream string,
acceptor AcceptGenericDeliveryUseCase,
malformedRecorder MalformedCommandRecorder,
offsetStore StreamOffsetStore,
) *CommandConsumer {
t.Helper()
consumer, err := NewCommandConsumer(CommandConsumerConfig{
Client: client,
Stream: stream,
BlockTimeout: 20 * time.Millisecond,
Acceptor: acceptor,
MalformedRecorder: malformedRecorder,
OffsetStore: offsetStore,
Clock: testClock{now: time.Now().UTC().Truncate(time.Millisecond)},
}, testLogger())
require.NoError(t, err)
return consumer
}
func addRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
t.Helper()
return addRenderedCommandWithSubject(t, client, deliveryID, idempotencyKey, "Turn ready")
}
func addRenderedCommandWithSubject(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string, subject string) string {
t.Helper()
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
Stream: redisstate.Keyspace{}.DeliveryCommands(),
Values: map[string]any{
"delivery_id": deliveryID,
"source": "notification",
"payload_mode": "rendered",
"idempotency_key": idempotencyKey,
"requested_at_ms": "1775121700000",
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"` + subject + `","text_body":"Turn 54 is ready.","html_body":"<p>Turn 54 is ready.</p>","attachments":[]}`,
},
}).Result()
require.NoError(t, err)
return messageID
}
func addTemplateCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
t.Helper()
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
Stream: redisstate.Keyspace{}.DeliveryCommands(),
Values: map[string]any{
"delivery_id": deliveryID,
"source": "notification",
"payload_mode": "template",
"idempotency_key": idempotencyKey,
"requested_at_ms": "1775121700001",
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}`,
},
}).Result()
require.NoError(t, err)
return messageID
}
func addMalformedRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string {
t.Helper()
messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{
Stream: redisstate.Keyspace{}.DeliveryCommands(),
Values: map[string]any{
"delivery_id": deliveryID,
"source": "notification",
"payload_mode": "rendered",
"idempotency_key": idempotencyKey,
"requested_at_ms": "1775121700000",
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"text_body":"Turn 54 is ready.","attachments":[]}`,
},
}).Result()
require.NoError(t, err)
return messageID
}
type testClock struct {
now time.Time
}
func (clock testClock) Now() time.Time {
return clock.now
}
type scriptedOffsetStore struct {
lastEntryID string
found bool
saveErrs []error
saveCalls int
}
func (store *scriptedOffsetStore) Load(context.Context, string) (string, bool, error) {
if !store.found {
return "", false, nil
}
return store.lastEntryID, true, nil
}
func (store *scriptedOffsetStore) Save(_ context.Context, _ string, entryID string) error {
if store.saveCalls < len(store.saveErrs) && store.saveErrs[store.saveCalls] != nil {
store.saveCalls++
return store.saveErrs[store.saveCalls-1]
}
store.saveCalls++
store.lastEntryID = entryID
store.found = true
return nil
}
func testLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}