package dispatch import ( "context" "sync/atomic" "testing" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type dispatchWorkerListener struct { name string count atomic.Int32 } func (l *dispatchWorkerListener) Name() string { return l.name } func (l *dispatchWorkerListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { l.count.Add(1) return nil } func newDispatchWorkerDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:dispatch-worker?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { t.Fatalf("open sqlite: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("sqlite db handle: %v", err) } sqlDB.SetMaxOpenConns(1) if err := db.AutoMigrate(&model.BackgroundJob{}); err != nil { t.Fatalf("migrate background jobs: %v", err) } t.Cleanup(func() { db.Exec("DELETE FROM background_jobs") sqlDB.Close() }) return db } func TestEventDispatcherQueuesAsyncListenersDurably(t *testing.T) { db := newDispatchWorkerDB(t) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 30, 0, 0, time.UTC) })) ed := NewEventDispatcher(channel.NewDispatcher()) ed.SetWorkerPool(wp) syncListener := &dispatchWorkerListener{name: "sync-listener"} asyncListener := &dispatchWorkerListener{name: "async-listener"} ed.RegisterSync(syncListener, string(channel.EventConversationCreated)) ed.RegisterAsync(asyncListener, string(channel.EventConversationCreated)) event := channel.NewChannelEvent(channel.EventConversationCreated, channel.ChannelWebWidget, 1, 2) if err := ed.Dispatch(context.Background(), event); err != nil { t.Fatalf("dispatch: %v", err) } if syncListener.count.Load() != 1 { t.Fatalf("sync listener should run immediately") } if asyncListener.count.Load() != 0 { t.Fatalf("async listener should wait for durable worker") } var count int64 if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeEventListenerDispatch, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil { t.Fatalf("count jobs: %v", err) } if count != 1 { t.Fatalf("expected one queued listener dispatch job, got %d", count) } processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process listener job: processed=%v err=%v", processed, err) } if asyncListener.count.Load() != 1 { t.Fatalf("async listener was not called by worker") } } func TestEventDispatcherDispatchAsyncQueuesAllMatchingListeners(t *testing.T) { db := newDispatchWorkerDB(t) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 45, 0, 0, time.UTC) })) ed := NewEventDispatcher(channel.NewDispatcher()) ed.SetWorkerPool(wp) one := &dispatchWorkerListener{name: "one"} two := &dispatchWorkerListener{name: "two"} ed.RegisterSync(one, string(channel.EventMessageCreated)) ed.RegisterAsync(two, string(channel.EventMessageCreated)) ed.DispatchAsync(context.Background(), channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, 1, 2)) for i := 0; i < 2; i++ { processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process listener job %d: processed=%v err=%v", i, processed, err) } } if one.count.Load() != 1 || two.count.Load() != 1 { t.Fatalf("expected both listeners via durable jobs, got one=%d two=%d", one.count.Load(), two.count.Load()) } }