Complete notification expiry and configurable durable delivery (#36)

This commit is contained in:
Stephan D committed 2026-09-12 14:52:44 +02:00
1 parent 326d58f705
commit 4b14649b3a
16 files changed
+320 -40

No files matched your search

@@ -3,6 +3,7 @@ package service_test
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -10,6 +11,7 @@ import (
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/store"
@@ -18,19 +20,30 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type sender struct {
calls atomic.Int32
state model.State
calls atomic.Int32
mu sync.Mutex
messages map[string]model.Email
state model.State
}
func (s *sender) Send(context.Context, string, model.Email) email.Outcome {
func (s *sender) Send(_ context.Context, id string, message model.Email) email.Outcome {
s.mu.Lock()
if s.messages == nil {
s.messages = map[string]model.Email{}
}
s.messages[id] = message
s.mu.Unlock()
s.calls.Add(1)
return email.Outcome{State: s.state, Code: "test", RetryAfter: time.Millisecond}
}
@@ -159,6 +172,25 @@ func TestDurableDelivery(t *testing.T) {
if err != nil || retry.State != model.RetryWait || retryDriver.calls.Load() != 1 {
t.Fatal(retry, err)
}
// Expire both queued and retrying notifications without a connected broker.
for _, state := range []model.State{model.Queued, model.RetryWait} {
request.Key = "offline-expiry-" + string(state)
expired, e := svc.Send(ctx, "product", request)
if e != nil {
t.Fatal(e)
}
patch := repository.Patch().Set(repository.Field("state"), state).Set(repository.Field("expires_at"), time.Now().Add(-time.Minute)).Set(repository.Field("next_attempt_at"), time.Now().Add(time.Hour))
if e = repo.DBImp.Patch(ctx, expired.ID, patch); e != nil {
t.Fatal(e)
}
if e = repo.Recover(ctx, time.Now()); e != nil {
t.Fatal(e)
}
expired, e = svc.Get(ctx, "product", expired.ID.Hex())
if e != nil || expired.State != model.Expired || expired.Email.Address != "" {
t.Fatal("offline expiry failed", expired, e)
}
}
// Verify the real JetStream publisher/consumer path with an isolated broker.
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
if err != nil {
@@ -175,16 +207,48 @@ func TestDurableDelivery(t *testing.T) {
t.Fatal(err)
}
defer rt.Close()
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop())
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop(), bus.DefaultConfig())
if err != nil {
t.Fatal(err)
}
defer broker.Close()
request.Key = "jetstream"
job, err := svc.Send(ctx, "product", request)
// Production ops mode validates existing resources without silently rewriting drift.
queue := bus.DefaultConfig()
settings := reliable.DurableSettings{Stream: queue.Stream, Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningOps, Environment: reliable.DurableEnvironmentProduction}}
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
t.Fatal(err)
}
settings.Stream.MaxAgeHours = 72
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("retention drift accepted", err)
}
settings.Stream.MaxAgeHours = 48
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
t.Fatal("ops mode mutated the stream", err)
}
canceled, stopAdmin := context.WithCancel(ctx)
stopAdmin()
if err = rt.DurableAdmin.EnsureProducerResources(canceled, settings); err == nil {
t.Fatal("canceled provisioning succeeded")
}
client := grpcClient(t, svc)
rpcCtx := callerContext(ctx, "product")
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
t.Fatal("unauthenticated request accepted", e)
}
sent, err := client.Send(rpcCtx, input)
if err != nil {
t.Fatal(err)
}
replay, err := client.Send(rpcCtx, input)
if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err)
}
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound {
t.Fatal("RPC caller isolation failed", e)
}
if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err)
}
@@ -194,16 +258,25 @@ func TestDurableDelivery(t *testing.T) {
defer func() { stop(); <-done }()
deadline := time.Now().Add(10 * time.Second)
for {
job, err = svc.Get(ctx, "product", job.ID.Hex())
if err != nil {
t.Fatal(err)
result, e := client.Get(rpcCtx, &pb.GetRequest{Id: sent.Notification.Id})
if e != nil {
t.Fatal(e)
}
if job.State == model.Accepted {
if strings.Contains(result.String(), "user@example.com") || strings.Contains(result.String(), "escaped") {
t.Fatal("response exposes email content")
}
if result.Notification.Deliveries[0].State == pb.DeliveryState_DELIVERY_STATE_ACCEPTED {
break
}
if time.Now().After(deadline) {
t.Fatal("JetStream job did not complete", job.State)
t.Fatal("JetStream job did not complete", result.Notification.Deliveries[0].State)
}
time.Sleep(20 * time.Millisecond)
}
driver.mu.Lock()
delivered := driver.messages[sent.Notification.Id]
driver.mu.Unlock()
if delivered.HTML != "<p>End to end &amp; escaped</p>" || delivered.Subject != "Hello" || delivered.Address != "user@example.com" {
t.Fatal("parameters did not reach driver", delivered)
}
}