diff --git a/workspace-server/internal/channels/manager.go b/workspace-server/internal/channels/manager.go index e0636349..63cfe950 100644 --- a/workspace-server/internal/channels/manager.go +++ b/workspace-server/internal/channels/manager.go @@ -402,7 +402,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin return err } - adapter, ok := GetAdapter(ch.ChannelType) + adapter, ok := GetSendAdapter(ch.ChannelType) if !ok { return fmt.Errorf("no adapter for %s", ch.ChannelType) } diff --git a/workspace-server/internal/channels/registry.go b/workspace-server/internal/channels/registry.go index 3f7e53fd..fa511874 100644 --- a/workspace-server/internal/channels/registry.go +++ b/workspace-server/internal/channels/registry.go @@ -1,5 +1,7 @@ package channels +import "context" + // Registry of all available channel adapters. // To add a new platform: implement ChannelAdapter, register here. var adapters = map[string]ChannelAdapter{ @@ -9,6 +11,27 @@ var adapters = map[string]ChannelAdapter{ "discord": &DiscordAdapter{}, } +// SendAdapter is the subset of ChannelAdapter needed by SendOutbound. +// Extracted so tests can inject a no-op/mock adapter without hitting real +// platform APIs (Telegram Bot API, Slack API, etc.). +type SendAdapter interface { + SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error +} + +// getSendAdapter is the production implementation of GetSendAdapter — +// returns the real registered adapter's SendMessage method. +func getSendAdapter(channelType string) (SendAdapter, bool) { + a, ok := adapters[channelType] + if !ok { + return nil, false + } + return a, true +} + +// GetSendAdapter returns the SendAdapter for a channel type. +// Defaults to the real adapter; overridden by SetTestSendAdapter in tests. +var GetSendAdapter = getSendAdapter + // GetAdapter returns the adapter for a channel type. func GetAdapter(channelType string) (ChannelAdapter, bool) { a, ok := adapters[channelType] diff --git a/workspace-server/internal/channels/testing.go b/workspace-server/internal/channels/testing.go new file mode 100644 index 00000000..9a801a63 --- /dev/null +++ b/workspace-server/internal/channels/testing.go @@ -0,0 +1,30 @@ +package channels + +import "context" + +// MockSendAdapter implements SendAdapter for handler tests. It records every +// call and returns a configurable error (nil = success, non-nil = failure). +type MockSendAdapter struct { + Calls int + Err error + SentText string + SentChat string +} + +func (m *MockSendAdapter) SendMessage(_ context.Context, _ map[string]interface{}, chatID string, text string) error { + m.Calls++ + m.SentText = text + m.SentChat = chatID + return m.Err +} + +// SetGetSendAdapter replaces the package-level GetSendAdapter variable. +// Tests MUST call ResetSendAdapters() in their t.Cleanup. +func SetGetSendAdapter(fn func(string) (SendAdapter, bool)) { + GetSendAdapter = fn +} + +// ResetSendAdapters restores GetSendAdapter to the production implementation. +func ResetSendAdapters() { + GetSendAdapter = getSendAdapter +} diff --git a/workspace-server/internal/handlers/channels_test.go b/workspace-server/internal/handlers/channels_test.go index d05909ea..5ff0aef3 100644 --- a/workspace-server/internal/handlers/channels_test.go +++ b/workspace-server/internal/handlers/channels_test.go @@ -327,6 +327,207 @@ func TestChannelHandler_Send_EmptyText(t *testing.T) { } } +// ==================== Test (send outbound) ==================== + +// TestChannelHandler_Test_Success exercises the /channels/:channelId/test endpoint +// with a mock SendAdapter so the full success path is covered without hitting real +// Telegram/Slack/etc. APIs. +func TestChannelHandler_Test_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewChannelHandler(newTestChannelManager()) + + mockAdapter := &channels.MockSendAdapter{Err: nil} + channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) { + if ct == "telegram" { + return mockAdapter, true + } + return channels.GetSendAdapter(ct) + }) + t.Cleanup(channels.ResetSendAdapters) + + // loadChannel → valid row + mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id"). + WithArgs("ch-test-ok"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "channel_type", "channel_config", + "enabled", "allowed_users", + }).AddRow("ch-test-ok", "ws-1", "telegram", + `{"bot_token":"123:AAA","chat_id":"-100"}`, + true, `[]`)) + + // UPDATE message_count + last_message_at + mock.ExpectExec("UPDATE workspace_channels SET last_message_at"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-test-ok/test", nil) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-test-ok"}} + + handler.Test(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "ok" { + t.Errorf("expected status 'ok', got %v", resp["status"]) + } + if mockAdapter.Calls != 1 { + t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls) + } + if mockAdapter.SentChat != "-100" { + t.Errorf("expected chat_id '-100', got %q", mockAdapter.SentChat) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestChannelHandler_Test_ChannelNotFound verifies that when loadChannel returns +// no rows, the Test handler returns 500 with a "test message failed" error. +func TestChannelHandler_Test_ChannelNotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewChannelHandler(newTestChannelManager()) + + // loadChannel → no rows + mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id"). + WithArgs("ch-missing"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "channel_type", "channel_config", + "enabled", "allowed_users", + })) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-missing/test", nil) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-missing"}} + + handler.Test(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != "test message failed" { + t.Errorf("expected error 'test message failed', got %v", resp["error"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestChannelHandler_Send_Success covers the full outbound send success path: +// budget check passes → loadChannel → mock SendMessage succeeds → UPDATE count → 200. +func TestChannelHandler_Send_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewChannelHandler(newTestChannelManager()) + + mockAdapter := &channels.MockSendAdapter{Err: nil} + channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) { + if ct == "telegram" { + return mockAdapter, true + } + return channels.GetSendAdapter(ct) + }) + t.Cleanup(channels.ResetSendAdapters) + + // Budget check: count=0, no budget limit + mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). + WithArgs("ch-send-ok"). + WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). + AddRow(0, nil)) + + // loadChannel → valid row + mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id"). + WithArgs("ch-send-ok"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "channel_type", "channel_config", + "enabled", "allowed_users", + }).AddRow("ch-send-ok", "ws-1", "telegram", + `{"bot_token":"123:AAA","chat_id":"-100"}`, + true, `[]`)) + + // UPDATE message_count + mock.ExpectExec("UPDATE workspace_channels SET last_message_at"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + body, _ := json.Marshal(map[string]string{"text": "hello from test"}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-ok/send", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-ok"}} + + handler.Send(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "sent" { + t.Errorf("expected status 'sent', got %v", resp["status"]) + } + if mockAdapter.Calls != 1 { + t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls) + } + if mockAdapter.SentText != "hello from test" { + t.Errorf("expected 'hello from test', got %q", mockAdapter.SentText) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestChannelHandler_Send_ChannelNotFound verifies that after the budget check +// passes, a missing channel returns 500 (not 404) with "send failed". +func TestChannelHandler_Send_ChannelNotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewChannelHandler(newTestChannelManager()) + + // Budget check passes (NULL budget → no limit) + mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). + WithArgs("ch-send-missing"). + WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). + AddRow(0, nil)) + + // loadChannel → no rows + mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id"). + WithArgs("ch-send-missing"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "channel_type", "channel_config", + "enabled", "allowed_users", + })) + + body, _ := json.Marshal(map[string]string{"text": "hello"}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-missing/send", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-missing"}} + + handler.Send(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != "send failed" { + t.Errorf("expected error 'send failed', got %v", resp["error"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + // ==================== Webhook ==================== func TestChannelHandler_Webhook_UnknownType(t *testing.T) {