155 lines
5.8 KiB
Go
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))
|
||
|
|
}
|