feat: use postgres
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/idempotency"
|
||||
"galaxy/mail/internal/domain/malformedcommand"
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
"galaxy/mail/internal/service/acceptgenericdelivery"
|
||||
"galaxy/mail/internal/service/executeattempt"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/renderdelivery"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureDeliveryID common.DeliveryID = "delivery-001"
|
||||
fixtureKey common.IdempotencyKey = "key-001"
|
||||
fixtureFingerprint = "sha256:abcdef"
|
||||
fixtureRecipient common.Email = "user@example.com"
|
||||
)
|
||||
|
||||
func fixtureNow() time.Time {
|
||||
return time.Date(2026, time.April, 26, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func fixtureAuthDelivery(id common.DeliveryID, key common.IdempotencyKey, status deliverydomain.Status) deliverydomain.Delivery {
|
||||
now := fixtureNow()
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: id,
|
||||
Source: deliverydomain.SourceAuthSession,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{fixtureRecipient}},
|
||||
Content: deliverydomain.Content{Subject: "Login code", TextBody: "Your code is 123456"},
|
||||
IdempotencyKey: key,
|
||||
Status: status,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if status == deliverydomain.StatusSuppressed {
|
||||
record.AttemptCount = 0
|
||||
record.SuppressedAt = &now
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func fixtureGenericDelivery(id common.DeliveryID, key common.IdempotencyKey) deliverydomain.Delivery {
|
||||
now := fixtureNow()
|
||||
return deliverydomain.Delivery{
|
||||
DeliveryID: id,
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("generic-news"),
|
||||
Locale: common.Locale("en"),
|
||||
TemplateVariables: map[string]any{"name": "Alice"},
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{fixtureRecipient}, ReplyTo: []common.Email{"reply@example.com"}},
|
||||
Attachments: []common.AttachmentMetadata{{Filename: "f.txt", ContentType: "text/plain", SizeBytes: 5}},
|
||||
IdempotencyKey: key,
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureFirstAttempt(id common.DeliveryID, attemptNo int) attempt.Attempt {
|
||||
now := fixtureNow().Add(time.Minute)
|
||||
return attempt.Attempt{
|
||||
DeliveryID: id,
|
||||
AttemptNo: attemptNo,
|
||||
Status: attempt.StatusScheduled,
|
||||
ScheduledFor: now,
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureIdempotency(source deliverydomain.Source, id common.DeliveryID, key common.IdempotencyKey) idempotency.Record {
|
||||
now := fixtureNow()
|
||||
return idempotency.Record{
|
||||
Source: source,
|
||||
IdempotencyKey: key,
|
||||
DeliveryID: id,
|
||||
RequestFingerprint: fixtureFingerprint,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAcceptanceCreate_GetIdempotency_GetDelivery(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureAuthDelivery(fixtureDeliveryID, fixtureKey, deliverydomain.StatusQueued)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: &first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create acceptance: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetIdempotency(ctx, delivery.Source, delivery.IdempotencyKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get idempotency: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("idempotency not found")
|
||||
}
|
||||
if got.DeliveryID != delivery.DeliveryID || got.RequestFingerprint != fixtureFingerprint {
|
||||
t.Fatalf("idempotency mismatch: %+v", got)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.GetDelivery(ctx, delivery.DeliveryID)
|
||||
if err != nil {
|
||||
t.Fatalf("get delivery: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("delivery not found")
|
||||
}
|
||||
if loaded.DeliveryID != delivery.DeliveryID || loaded.Status != deliverydomain.StatusQueued {
|
||||
t.Fatalf("delivery mismatch: %+v", loaded)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Envelope.To, []common.Email{fixtureRecipient}) {
|
||||
t.Fatalf("envelope.to mismatch: %+v", loaded.Envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAcceptanceConflict(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureAuthDelivery(fixtureDeliveryID, fixtureKey, deliverydomain.StatusQueued)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: &first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
|
||||
dup := delivery
|
||||
dup.DeliveryID = "delivery-002"
|
||||
dupAttempt := fixtureFirstAttempt(dup.DeliveryID, 1)
|
||||
dupIdem := idem
|
||||
dupIdem.DeliveryID = dup.DeliveryID
|
||||
|
||||
err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: dup,
|
||||
FirstAttempt: &dupAttempt,
|
||||
Idempotency: dupIdem,
|
||||
})
|
||||
if !errors.Is(err, acceptauthdelivery.ErrConflict) {
|
||||
t.Fatalf("expected acceptauthdelivery.ErrConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericAcceptanceCreate_GetDeliveryPayload(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureGenericDelivery(fixtureDeliveryID, fixtureKey)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
payload := &acceptgenericdelivery.DeliveryPayload{
|
||||
DeliveryID: delivery.DeliveryID,
|
||||
Attachments: []acceptgenericdelivery.AttachmentPayload{{
|
||||
Filename: "f.txt",
|
||||
ContentType: "text/plain",
|
||||
ContentBase64: "aGVsbG8=", // "hello"
|
||||
SizeBytes: 5,
|
||||
}},
|
||||
}
|
||||
|
||||
handle := store.GenericAcceptance()
|
||||
if err := handle.CreateAcceptance(ctx, acceptgenericdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: first,
|
||||
DeliveryPayload: payload,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create generic acceptance: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetDeliveryPayload(ctx, delivery.DeliveryID)
|
||||
if err != nil {
|
||||
t.Fatalf("get delivery payload: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("payload not found")
|
||||
}
|
||||
if got.DeliveryID != delivery.DeliveryID || len(got.Attachments) != 1 {
|
||||
t.Fatalf("payload mismatch: %+v", got)
|
||||
}
|
||||
if got.Attachments[0].ContentBase64 != "aGVsbG8=" {
|
||||
t.Fatalf("payload base64 mismatch: %+v", got.Attachments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerClaimAndCommit(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureAuthDelivery(fixtureDeliveryID, fixtureKey, deliverydomain.StatusQueued)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: &first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create acceptance: %v", err)
|
||||
}
|
||||
|
||||
scheduler := store.AttemptExecution()
|
||||
now := first.ScheduledFor.Add(time.Second)
|
||||
ids, err := scheduler.NextDueDeliveryIDs(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("next due: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != delivery.DeliveryID {
|
||||
t.Fatalf("next due ids: %+v", ids)
|
||||
}
|
||||
|
||||
claimed, ok, err := scheduler.ClaimDueAttempt(ctx, delivery.DeliveryID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("claim due: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("claim due: not found")
|
||||
}
|
||||
if claimed.Delivery.Status != deliverydomain.StatusSending {
|
||||
t.Fatalf("expected sending, got %q", claimed.Delivery.Status)
|
||||
}
|
||||
if claimed.Attempt.Status != attempt.StatusInProgress {
|
||||
t.Fatalf("expected in_progress, got %q", claimed.Attempt.Status)
|
||||
}
|
||||
|
||||
// After claim, the row should not be picked up again.
|
||||
again, err := scheduler.NextDueDeliveryIDs(ctx, now.Add(time.Second), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("next due (after claim): %v", err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("expected zero due deliveries after claim, got %+v", again)
|
||||
}
|
||||
|
||||
completed := claimed.Attempt
|
||||
finishedAt := now.Add(time.Second)
|
||||
completed.Status = attempt.StatusProviderAccepted
|
||||
completed.FinishedAt = &finishedAt
|
||||
completed.ProviderClassification = "accepted"
|
||||
completed.ProviderSummary = "ok"
|
||||
|
||||
finalDelivery := claimed.Delivery
|
||||
finalDelivery.Status = deliverydomain.StatusSent
|
||||
finalDelivery.LastAttemptStatus = attempt.StatusProviderAccepted
|
||||
finalDelivery.SentAt = &finishedAt
|
||||
finalDelivery.UpdatedAt = finishedAt
|
||||
finalDelivery.ProviderSummary = "ok"
|
||||
|
||||
if err := scheduler.Commit(ctx, executeattempt.CommitStateInput{
|
||||
Delivery: finalDelivery,
|
||||
Attempt: completed,
|
||||
}); err != nil {
|
||||
t.Fatalf("commit attempt: %v", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.GetDelivery(ctx, delivery.DeliveryID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get delivery after commit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != deliverydomain.StatusSent {
|
||||
t.Fatalf("expected sent, got %q", loaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkRendered(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureGenericDelivery(fixtureDeliveryID, fixtureKey)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
if err := store.GenericAcceptance().CreateAcceptance(ctx, acceptgenericdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create acceptance: %v", err)
|
||||
}
|
||||
|
||||
rendered := delivery
|
||||
rendered.Status = deliverydomain.StatusRendered
|
||||
rendered.Content = deliverydomain.Content{Subject: "Hello Alice", TextBody: "Hi"}
|
||||
rendered.UpdatedAt = fixtureNow().Add(time.Second)
|
||||
|
||||
if err := store.RenderDelivery().MarkRendered(ctx, renderdelivery.MarkRenderedInput{Delivery: rendered}); err != nil {
|
||||
t.Fatalf("mark rendered: %v", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.GetDelivery(ctx, delivery.DeliveryID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get delivery: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != deliverydomain.StatusRendered {
|
||||
t.Fatalf("expected rendered, got %q", loaded.Status)
|
||||
}
|
||||
if loaded.Content.Subject != "Hello Alice" {
|
||||
t.Fatalf("subject mismatch: %q", loaded.Content.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDeliveriesPaging(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := range 3 {
|
||||
key := common.IdempotencyKey([]byte{'k', '0' + byte(i)})
|
||||
id := common.DeliveryID([]byte{'d', '0' + byte(i)})
|
||||
delivery := fixtureAuthDelivery(id, key, deliverydomain.StatusQueued)
|
||||
// Stagger created_at so listing order is deterministic.
|
||||
delivery.CreatedAt = fixtureNow().Add(time.Duration(i) * time.Second)
|
||||
delivery.UpdatedAt = delivery.CreatedAt
|
||||
first := fixtureFirstAttempt(id, 1)
|
||||
first.ScheduledFor = delivery.CreatedAt.Add(time.Minute)
|
||||
idem := fixtureIdempotency(delivery.Source, id, key)
|
||||
idem.CreatedAt = delivery.CreatedAt
|
||||
idem.ExpiresAt = delivery.CreatedAt.Add(7 * 24 * time.Hour)
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: &first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
page1, err := store.List(ctx, listdeliveries.Input{Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("list page 1: %v", err)
|
||||
}
|
||||
if len(page1.Items) != 2 || page1.NextCursor == nil {
|
||||
t.Fatalf("page 1 unexpected: items=%d cursor=%v", len(page1.Items), page1.NextCursor)
|
||||
}
|
||||
if page1.Items[0].DeliveryID != "d2" || page1.Items[1].DeliveryID != "d1" {
|
||||
t.Fatalf("page 1 ordering: %+v", []common.DeliveryID{page1.Items[0].DeliveryID, page1.Items[1].DeliveryID})
|
||||
}
|
||||
|
||||
page2, err := store.List(ctx, listdeliveries.Input{Limit: 2, Cursor: page1.NextCursor})
|
||||
if err != nil {
|
||||
t.Fatalf("list page 2: %v", err)
|
||||
}
|
||||
if len(page2.Items) != 1 || page2.NextCursor != nil {
|
||||
t.Fatalf("page 2 unexpected: items=%d cursor=%v", len(page2.Items), page2.NextCursor)
|
||||
}
|
||||
if page2.Items[0].DeliveryID != "d0" {
|
||||
t.Fatalf("page 2 expected d0, got %s", page2.Items[0].DeliveryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAttemptsAndDeadLetter(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
delivery := fixtureAuthDelivery(fixtureDeliveryID, fixtureKey, deliverydomain.StatusQueued)
|
||||
first := fixtureFirstAttempt(delivery.DeliveryID, 1)
|
||||
idem := fixtureIdempotency(delivery.Source, delivery.DeliveryID, delivery.IdempotencyKey)
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: delivery,
|
||||
FirstAttempt: &first,
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create acceptance: %v", err)
|
||||
}
|
||||
|
||||
// Claim and commit a transport_failed → next attempt scheduled (delivery
|
||||
// stays queued); then claim attempt 2 and commit dead-letter.
|
||||
scheduler := store.AttemptExecution()
|
||||
now := first.ScheduledFor.Add(time.Second)
|
||||
claimed1, ok, err := scheduler.ClaimDueAttempt(ctx, delivery.DeliveryID, now)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("claim attempt 1: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
finishedAt1 := now.Add(time.Second)
|
||||
terminal1 := claimed1.Attempt
|
||||
terminal1.Status = attempt.StatusTransportFailed
|
||||
terminal1.FinishedAt = &finishedAt1
|
||||
terminal1.ProviderClassification = "transport_failed"
|
||||
|
||||
nextAttempt := attempt.Attempt{
|
||||
DeliveryID: delivery.DeliveryID,
|
||||
AttemptNo: 2,
|
||||
Status: attempt.StatusScheduled,
|
||||
ScheduledFor: finishedAt1.Add(5 * time.Minute),
|
||||
}
|
||||
|
||||
delivery2 := claimed1.Delivery
|
||||
delivery2.Status = deliverydomain.StatusQueued
|
||||
delivery2.LastAttemptStatus = attempt.StatusTransportFailed
|
||||
delivery2.AttemptCount = 2
|
||||
delivery2.UpdatedAt = finishedAt1
|
||||
|
||||
if err := scheduler.Commit(ctx, executeattempt.CommitStateInput{
|
||||
Delivery: delivery2,
|
||||
Attempt: terminal1,
|
||||
NextAttempt: &nextAttempt,
|
||||
}); err != nil {
|
||||
t.Fatalf("commit attempt 1: %v", err)
|
||||
}
|
||||
|
||||
// Claim attempt 2.
|
||||
now2 := nextAttempt.ScheduledFor.Add(time.Second)
|
||||
claimed2, ok, err := scheduler.ClaimDueAttempt(ctx, delivery.DeliveryID, now2)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("claim attempt 2: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
finishedAt2 := now2.Add(time.Second)
|
||||
terminal2 := claimed2.Attempt
|
||||
terminal2.Status = attempt.StatusTransportFailed
|
||||
terminal2.FinishedAt = &finishedAt2
|
||||
terminal2.ProviderClassification = "retry_exhausted"
|
||||
|
||||
dlEntry := &deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: delivery.DeliveryID,
|
||||
FinalAttemptNo: 2,
|
||||
FailureClassification: "retry_exhausted",
|
||||
CreatedAt: finishedAt2,
|
||||
}
|
||||
|
||||
delivery3 := claimed2.Delivery
|
||||
delivery3.Status = deliverydomain.StatusDeadLetter
|
||||
delivery3.LastAttemptStatus = attempt.StatusTransportFailed
|
||||
delivery3.DeadLetteredAt = &finishedAt2
|
||||
delivery3.UpdatedAt = finishedAt2
|
||||
|
||||
if err := scheduler.Commit(ctx, executeattempt.CommitStateInput{
|
||||
Delivery: delivery3,
|
||||
Attempt: terminal2,
|
||||
DeadLetter: dlEntry,
|
||||
}); err != nil {
|
||||
t.Fatalf("commit attempt 2: %v", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.GetDelivery(ctx, delivery.DeliveryID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get delivery: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != deliverydomain.StatusDeadLetter {
|
||||
t.Fatalf("expected dead_letter, got %q", loaded.Status)
|
||||
}
|
||||
|
||||
dl, ok, err := store.GetDeadLetter(ctx, delivery.DeliveryID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get dead-letter: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if dl.FailureClassification != "retry_exhausted" {
|
||||
t.Fatalf("dead-letter mismatch: %+v", dl)
|
||||
}
|
||||
|
||||
attempts, err := store.ListAttempts(ctx, delivery.DeliveryID, loaded.AttemptCount)
|
||||
if err != nil {
|
||||
t.Fatalf("list attempts: %v", err)
|
||||
}
|
||||
if len(attempts) != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", len(attempts))
|
||||
}
|
||||
if attempts[0].AttemptNo != 1 || attempts[1].AttemptNo != 2 {
|
||||
t.Fatalf("attempt sequence: %+v", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedCommandRecord(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
entry := malformedcommand.Entry{
|
||||
StreamEntryID: "1234-0",
|
||||
DeliveryID: "delivery-x",
|
||||
Source: "notification",
|
||||
IdempotencyKey: "k",
|
||||
FailureCode: malformedcommand.FailureCodeInvalidPayload,
|
||||
FailureMessage: "missing required field",
|
||||
RawFields: map[string]any{"raw": "value"},
|
||||
RecordedAt: fixtureNow(),
|
||||
}
|
||||
if err := store.Record(ctx, entry); err != nil {
|
||||
t.Fatalf("record malformed: %v", err)
|
||||
}
|
||||
// Idempotent re-record: same entry should not error.
|
||||
if err := store.Record(ctx, entry); err != nil {
|
||||
t.Fatalf("re-record malformed: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.GetMalformedCommand(ctx, entry.StreamEntryID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get malformed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.FailureCode != malformedcommand.FailureCodeInvalidPayload {
|
||||
t.Fatalf("failure code mismatch: %q", got.FailureCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendCreate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
parent := fixtureAuthDelivery(fixtureDeliveryID, fixtureKey, deliverydomain.StatusQueued)
|
||||
parentAttempt := fixtureFirstAttempt(parent.DeliveryID, 1)
|
||||
parentIdem := fixtureIdempotency(parent.Source, parent.DeliveryID, parent.IdempotencyKey)
|
||||
if err := store.CreateAcceptance(ctx, acceptauthdelivery.CreateAcceptanceInput{
|
||||
Delivery: parent,
|
||||
FirstAttempt: &parentAttempt,
|
||||
Idempotency: parentIdem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
cloneID := common.DeliveryID("clone-001")
|
||||
cloneIdempKey := common.IdempotencyKey("resend-clone-001")
|
||||
now := fixtureNow().Add(time.Hour)
|
||||
clone := deliverydomain.Delivery{
|
||||
DeliveryID: cloneID,
|
||||
ResendParentDeliveryID: parent.DeliveryID,
|
||||
Source: deliverydomain.SourceOperatorResend,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: parent.Envelope,
|
||||
Content: parent.Content,
|
||||
IdempotencyKey: cloneIdempKey,
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
cloneAttempt := attempt.Attempt{
|
||||
DeliveryID: cloneID,
|
||||
AttemptNo: 1,
|
||||
Status: attempt.StatusScheduled,
|
||||
ScheduledFor: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
if err := store.CreateResend(ctx, resenddelivery.CreateResendInput{
|
||||
Delivery: clone,
|
||||
FirstAttempt: cloneAttempt,
|
||||
}); err != nil {
|
||||
t.Fatalf("create resend: %v", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.GetDelivery(ctx, cloneID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get clone: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.ResendParentDeliveryID != parent.DeliveryID {
|
||||
t.Fatalf("expected resend parent %q, got %q", parent.DeliveryID, loaded.ResendParentDeliveryID)
|
||||
}
|
||||
|
||||
// Resend deliveries do not surface as idempotency hits.
|
||||
_, ok, err = store.GetIdempotency(ctx, deliverydomain.SourceOperatorResend, cloneIdempKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get idempotency for resend: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("resend delivery should not surface as idempotency hit")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user