package ws import ( "context" "path/filepath" "sync" "testing" "time" "everypublish/server/internal/config" "everypublish/server/internal/models" "everypublish/server/internal/platform" "everypublish/server/internal/task" gormsqlite "gorm.io/driver/sqlite" "gorm.io/gorm" ) type testPlatformAdapter struct { mu sync.Mutex called int block bool canceled chan struct{} } func (a *testPlatformAdapter) Platform() string { return "test-platform" } func (a *testPlatformAdapter) BeginLogin(context.Context) (platform.LoginSession, error) { return platform.LoginSession{}, nil } func (a *testPlatformAdapter) PollLogin(context.Context, string) (platform.LoginPoll, error) { return platform.LoginPoll{}, nil } func (a *testPlatformAdapter) SaveCredentials(uint64, uint64, string) error { return nil } func (a *testPlatformAdapter) Publish(ctx context.Context, in platform.PublishInput) (platform.PublishResult, error) { a.mu.Lock() a.called++ a.mu.Unlock() if a.block { select { case <-ctx.Done(): close(a.canceled) return platform.PublishResult{}, ctx.Err() case <-time.After(2 * time.Second): } } return platform.PublishResult{URL: "https://example.test/task/" + in.Title, Receipt: "receipt"}, nil } func platformTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(gormsqlite.Open(filepath.Join(t.TempDir(), "dispatcher.db")), &gorm.Config{}) if err != nil { t.Fatal(err) } if err = db.AutoMigrate(&models.Task{}, &models.TaskEvent{}, &models.Account{}, &models.Material{}, &models.TransferToken{}, &models.Notification{}); err != nil { t.Fatal(err) } return db } func TestDispatcherUsesRegisteredPlatformAndPublishesPerAccount(t *testing.T) { db := platformTestDB(t) account := models.Account{WorkspaceID: 1, Platform: "test-platform", Status: "active"} material := models.Material{WorkspaceID: 1, Name: "video.mp4", Status: "ready", StorageKey: "1/video.mp4"} if err := db.Create(&account).Error; err != nil { t.Fatal(err) } if err := db.Create(&material).Error; err != nil { t.Fatal(err) } row := models.Task{WorkspaceID: 1, Title: "platform", Status: task.Queued, AccountIDs: "[1]", MaterialIDs: "[1]"} if err := db.Create(&row).Error; err != nil { t.Fatal(err) } adapter := &testPlatformAdapter{} registry := platform.NewRegistry() if err := registry.Register(adapter); err != nil { t.Fatal(err) } d := NewDispatcher(db, NewHub(), &config.Config{ExecutorMode: "web", BaseURL: "http://127.0.0.1:8090"}) d.Registry = registry if !d.TryDispatch(row.ID) { t.Fatal("registered adapter should accept queued task") } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { var got models.Task if err := db.First(&got, row.ID).Error; err != nil { t.Fatal(err) } if got.Status == task.Success { if got.PublishedURLs != `["https://example.test/task/platform"]` || got.Receipts != `["receipt"]` { t.Fatalf("unexpected platform result: urls=%s receipts=%s", got.PublishedURLs, got.Receipts) } adapter.mu.Lock() called := adapter.called adapter.mu.Unlock() if called != 1 { t.Fatalf("adapter called %d times", called) } return } time.Sleep(20 * time.Millisecond) } t.Fatal("platform task did not reach success") } func TestDispatcherCancelStopsRegisteredPlatform(t *testing.T) { db := platformTestDB(t) account := models.Account{WorkspaceID: 1, Platform: "test-platform", Status: "active"} material := models.Material{WorkspaceID: 1, Name: "video.mp4", Status: "ready", StorageKey: "1/video.mp4"} _ = db.Create(&account).Error _ = db.Create(&material).Error row := models.Task{WorkspaceID: 1, Title: "cancel", Status: task.Queued, AccountIDs: "[1]", MaterialIDs: "[1]"} _ = db.Create(&row).Error adapter := &testPlatformAdapter{block: true, canceled: make(chan struct{})} registry := platform.NewRegistry() _ = registry.Register(adapter) d := NewDispatcher(db, NewHub(), &config.Config{ExecutorMode: "web", BaseURL: "http://127.0.0.1:8090"}) d.Registry = registry if !d.TryDispatch(row.ID) { t.Fatal("registered adapter should accept queued task") } time.Sleep(60 * time.Millisecond) d.Cancel(row.ID) select { case <-adapter.canceled: case <-time.After(time.Second): t.Fatal("adapter context was not cancelled") } }