392 lines
12 KiB
Go
392 lines
12 KiB
Go
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))
|
|
}
|