chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user