Files
story/internal/outbox/worker_integration_test.go
T

155 lines
5.8 KiB
Go

package outbox
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestWorkerProcessesAndDeduplicatesEvents(t *testing.T) {
pool := outboxIntegrationPool(t)
store := NewStore(pool)
worker := NewWorker(store, discardLogger(), WorkerOptions{MaxAttempts: 1, BaseRetry: time.Millisecond})
processed := 0
worker.Register("integration.noop", func(context.Context, Event) error {
processed++
return nil
})
deduplicationKey := fmt.Sprintf("integration-%d", time.Now().UnixNano())
tenantID := "10000000-0000-0000-0000-000000000001"
event := NewEvent{
TenantID: &tenantID, AggregateType: "household",
AggregateID: "10000000-0000-0000-0000-000000000201",
EventType: "integration.noop", DeduplicationKey: &deduplicationKey,
Payload: json.RawMessage(`{"ok":true}`),
}
firstID := enqueueIntegrationEvent(t, pool, store, event)
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM outbox_event WHERE id=$1::uuid`, firstID) })
secondID := enqueueIntegrationEvent(t, pool, store, event)
if firstID != secondID {
t.Fatalf("deduplicated event ids differ: %s %s", firstID, secondID)
}
event.Payload = json.RawMessage(`{"ok":false}`)
tx, err := pool.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := store.Enqueue(context.Background(), tx, event); !errors.Is(err, ErrDeduplicationConflict) {
_ = tx.Rollback(context.Background())
t.Fatalf("payload conflict = %v, want ErrDeduplicationConflict", err)
}
_ = tx.Rollback(context.Background())
count, err := worker.ProcessOnce(context.Background())
if err != nil || count < 1 || processed != 1 {
t.Fatalf("process result count=%d processed=%d err=%v", count, processed, err)
}
var status string
if err := pool.QueryRow(context.Background(), `SELECT status FROM outbox_event WHERE id = $1::uuid`, firstID).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "completed" {
t.Fatalf("event status = %s, want completed", status)
}
}
func TestWorkerMovesUnknownEventToDead(t *testing.T) {
pool := outboxIntegrationPool(t)
store := NewStore(pool)
worker := NewWorker(store, discardLogger(), WorkerOptions{MaxAttempts: 1, BaseRetry: time.Millisecond})
deduplicationKey := fmt.Sprintf("unknown-%d", time.Now().UnixNano())
eventID := enqueueIntegrationEvent(t, pool, store, NewEvent{
AggregateType: "platform", AggregateID: "10000000-0000-0000-0000-000000000201",
EventType: "integration.unknown", DeduplicationKey: &deduplicationKey,
Payload: json.RawMessage(`{"test":true}`),
})
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM outbox_event WHERE id=$1::uuid`, eventID) })
if _, err := worker.ProcessOnce(context.Background()); err != nil {
t.Fatal(err)
}
var status, errorCode string
var deadAt *time.Time
if err := pool.QueryRow(context.Background(), `
SELECT status, last_error_code, dead_at FROM outbox_event WHERE id = $1::uuid`, eventID,
).Scan(&status, &errorCode, &deadAt); err != nil {
t.Fatal(err)
}
if status != "dead" || errorCode != "HANDLER_NOT_REGISTERED" || deadAt == nil {
t.Fatalf("unknown event status=%s code=%s dead_at=%v", status, errorCode, deadAt)
}
}
func TestWorkerRetriesOnlyExplicitTemporaryErrors(t *testing.T) {
pool := outboxIntegrationPool(t)
store := NewStore(pool)
worker := NewWorker(store, discardLogger(), WorkerOptions{MaxAttempts: 4, BaseRetry: time.Millisecond})
worker.Register("integration.permanent", func(context.Context, Event) error { return errors.New("invalid payload") })
worker.Register("integration.temporary", func(context.Context, Event) error { return Retryable(errors.New("dependency unavailable")) })
permanentKey := fmt.Sprintf("permanent-%d", time.Now().UnixNano())
temporaryKey := fmt.Sprintf("temporary-%d", time.Now().UnixNano())
permanentID := enqueueIntegrationEvent(t, pool, store, NewEvent{AggregateType: "platform", AggregateID: "10000000-0000-0000-0000-000000000201", EventType: "integration.permanent", DeduplicationKey: &permanentKey, Payload: json.RawMessage(`{"test":true}`)})
temporaryID := enqueueIntegrationEvent(t, pool, store, NewEvent{AggregateType: "platform", AggregateID: "10000000-0000-0000-0000-000000000201", EventType: "integration.temporary", DeduplicationKey: &temporaryKey, Payload: json.RawMessage(`{"test":true}`)})
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM outbox_event WHERE id=ANY($1::uuid[])`, []string{permanentID, temporaryID})
})
if _, err := worker.ProcessOnce(context.Background()); err != nil {
t.Fatal(err)
}
for id, expected := range map[string]string{permanentID: "dead", temporaryID: "pending"} {
var status string
if err := pool.QueryRow(context.Background(), `SELECT status FROM outbox_event WHERE id=$1::uuid`, id).Scan(&status); err != nil {
t.Fatal(err)
}
if status != expected {
t.Fatalf("event %s status=%s, want %s", id, status, expected)
}
}
}
func enqueueIntegrationEvent(t *testing.T, pool *pgxpool.Pool, store *Store, event NewEvent) string {
t.Helper()
tx, err := pool.BeginTx(context.Background(), pgx.TxOptions{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
eventID, err := store.Enqueue(context.Background(), tx, event)
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(context.Background()); err != nil {
t.Fatal(err)
}
return eventID
}
func outboxIntegrationPool(t *testing.T) *pgxpool.Pool {
t.Helper()
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatalf("connect integration database: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}