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)) }