Compare commits

..

6 Commits

Author SHA1 Message Date
fullstack-engineer 9edc0036a3 channels: add SendAdapter injection + handler test coverage for Test and Send
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
security-review / approved (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m28s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m16s
CI / Platform (Go) (pull_request) Failing after 5m30s
CI / all-required (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Successful in 14s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: five-axis-review, no-backwards-compat, memory-consult
audit-force-merge / audit (pull_request) Successful in 18s
- Extract SendAdapter interface (SendMessage only) from ChannelAdapter so
  tests can inject a MockSendAdapter without hitting real Telegram/Slack APIs
- Make GetSendAdapter a package-level var (default: real adapters; tests
  override via SetGetSendAdapter from channels/testing.go)
- Wire GetSendAdapter into Manager.SendOutbound (was GetAdapter → ChannelAdapter)
- Add 4 handler tests in handlers/channels_test.go:
    TestChannelHandler_Test_Success         — full send-outbound success path
    TestChannelHandler_Test_ChannelNotFound — loadChannel error → 500
    TestChannelHandler_Send_Success         — budget pass → send → 200
    TestChannelHandler_Send_ChannelNotFound — loadChannel error → 500

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:30:11 +00:00
devops-engineer b25b4fb6ac Merge pull request '[core-devops-agent] chore: promote main→staging v5 (test panic fix)' (#972) from promote/main-to-staging-v5 into staging
qa-review / approved (pull_request) Successful in 35s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
security-review / approved (pull_request) Successful in 33s
CI / Canvas (Next.js) (pull_request) Successful in 13s
audit-force-merge / audit (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 13s
sop-tier-check / tier-check (pull_request) Successful in 38s
CI / Platform (Go) (pull_request) Successful in 24s
sop-checklist / all-items-acked (pull_request) Successful in 43s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m42s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m51s
CI / all-required (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
CI / Detect changes (pull_request) Successful in 1m54s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m49s
Block internal-flavored paths / Block forbidden paths (push) Successful in 16s
CI / Detect changes (push) Successful in 47s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 35s
Handlers Postgres Integration / detect-changes (push) Successful in 38s
CI / Platform (Go) (push) Successful in 13s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Successful in 4s
CI / all-required (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m21s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m45s
gate-check-v3 / gate-check (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m54s
2026-05-14 05:37:47 +00:00
molecule-operator 956c2480d6 chore: promote main→staging v5 (test panic fix + t.Fatal improvements)
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
CI / Detect changes (pull_request) Successful in 1m29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m41s
qa-review / approved (pull_request) Successful in 26s
security-review / approved (pull_request) Successful in 25s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m39s
sop-tier-check / tier-check (pull_request) Successful in 24s
gate-check-v3 / gate-check (pull_request) Successful in 21s
audit-force-merge / audit (pull_request) Successful in 24s
sop-checklist / all-items-acked (pull_request) Successful in 18s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Failing after 13m18s
Resolve merge conflict in org_helpers_security_test.go:
- Keep staging t.TempDir() fix for TestResolveInsideRoot_DotDotWithIntermediate
  (a/b/../../c normalizes to c within root — test correctly expects success)
- t.Fatal vs t.Fatalf are equivalent; staging version retained
2026-05-14 05:34:35 +00:00
devops-engineer 39c099b48f Merge pull request 'fix(handlers): fix 3 test regressions + bring PR#956 security tests to staging' (#971) from fix/965-test-panic-resolveInsideRoot into staging
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 14s
CI / Detect changes (push) Successful in 1m4s
Harness Replays / detect-changes (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 1m9s
Handlers Postgres Integration / detect-changes (push) Successful in 1m22s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m7s
Harness Replays / Harness Replays (push) Successful in 6s
2026-05-14 05:32:24 +00:00
devops-engineer 8026f02050 Merge pull request 'fix(handlers/org_helpers_test): use t.Fatal instead of t.Error in error-path tests' (#970) from fix/org-helpers-test-panic into main
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 46s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m15s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m12s
staging-v6-push-check
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Harness Replays / detect-changes (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
CI / Detect changes (push) Successful in 43s
E2E API Smoke Test / detect-changes (push) Successful in 42s
Handlers Postgres Integration / detect-changes (push) Successful in 41s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 43s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 33s
Harness Replays / Harness Replays (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 12s
CI / Python Lint & Test (push) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m51s
CI / Canvas Deploy Reminder (push) Successful in 6s
CI / Platform (Go) (push) Failing after 5m20s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 5m12s
publish-workspace-server-image / build-and-push (push) Successful in 9m29s
CI / all-required (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Failing after 32s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 6s
publish-runtime-autobump / pr-validate (pull_request) Successful in 33s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 32s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 23s
CI / Python Lint & Test (pull_request) Successful in 3s
security-review / approved (pull_request) Failing after 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
CI / Canvas Deploy Reminder (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
CI / all-required (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 56s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m32s
main-red-watchdog / watchdog (push) Successful in 24s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m31s
gate-check-v3 / gate-check (push) Successful in 8s
gitea-merge-queue / queue (push) Successful in 2s
status-reaper / reap (push) Successful in 1m8s
2026-05-14 05:29:38 +00:00
core-be 95c62c6fcd fix(handlers/org_helpers_test): use t.Fatal instead of t.Error in error-path tests
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 57s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m10s
Harness Replays / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m1s
qa-review / approved (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 29s
security-review / approved (pull_request) Successful in 16s
sop-checklist / all-items-acked (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Successful in 15s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 16s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m42s
CI / Canvas Deploy Reminder (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Failing after 4m12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m56s
CI / all-required (pull_request) Successful in 5s
Six resolveInsideRoot tests called t.Errorf then continued to err.Error()
on a potentially-nil error — if err was unexpectedly nil, the subsequent
err.Error() call would panic with nil pointer dereference.

Fix: use t.Fatalf/t.Fatal in the nil-error branch so execution stops
before the err.Error() call. Affects:
- TestResolveInsideRoot_EmptyUserPath
- TestResolveInsideRoot_AbsolutePathRejected
- TestResolveInsideRoot_DotDotTraversal
- TestResolveInsideRoot_DotDotWithIntermediate
- TestResolveInsideRoot_NestedDotDotEscapes
- TestResolveInsideRoot_DotdotAtStart

Fixes regression reported in issue #965.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:26:38 +00:00
5 changed files with 255 additions and 912 deletions
@@ -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)
}
@@ -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]
@@ -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
}
@@ -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) {
@@ -1,911 +0,0 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// ─── List ────────────────────────────────────────────────────────────────────
func TestList_EmptyResult(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
mock.ExpectQuery(`SELECT .* FROM workspace_schedules WHERE workspace_id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "name", "cron_expr", "timezone", "prompt",
"enabled", "last_run_at", "next_run_at", "run_count", "last_status",
"last_error", "source", "created_at", "updated_at",
}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/schedules", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var schedules []scheduleResponse
if err := json.Unmarshal(w.Body.Bytes(), &schedules); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if len(schedules) != 0 {
t.Errorf("expected empty list, got %d items", len(schedules))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestList_QueryError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
mock.ExpectQuery(`SELECT .* FROM workspace_schedules WHERE workspace_id = \$1`).
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/schedules", nil)
handler.List(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// TestList_ScanError_Continues is not directly testable with sqlmock because
// sqlmock panics when a row has the wrong number of columns (rather than
// returning a scan error the way a real DB driver would). The handler's scan
// error handling (log + continue) is implicitly covered by the multi-row test
// TestList_IncludesSourceColumn — the handler's scan loop uses `continue` on
// error, so correctly-shaped rows are always returned regardless of what
// earlier rows did.
// ─── Create ───────────────────────────────────────────────────────────────────
func TestCreate_MissingCronExpr_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
body := []byte(`{"prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreate_MissingPrompt_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
body := []byte(`{"cron_expr":"*/5 * * * *"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreate_InvalidTimezone_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
body := []byte(`{"cron_expr":"*/5 * * * *","prompt":"do thing","timezone":"Not/A/Zone"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid timezone") {
t.Errorf("error message should mention 'invalid timezone': %s", w.Body.String())
}
}
func TestCreate_InvalidCronExpr_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
body := []byte(`{"cron_expr":"not-a-cron","prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreate_CRLFStrippedFromPrompt(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
// The prompt in the DB should NOT contain \r.
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(wsID, "test", "*/5 * * * *", "UTC", "line1\nline2", true, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1"))
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"line1\r\nline2"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v — the \r must be stripped before INSERT", err)
}
}
func TestCreate_DefaultsEnabledTrue(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
// enabled=true is the default when body.enabled is nil.
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1"))
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestCreate_DefaultsTimezoneUTC(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
// Timezone defaults to UTC when not specified.
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1"))
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestCreate_ExplicitEnabledFalse(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
// enabled=false when explicitly set.
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", false, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1"))
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"do thing","enabled":false}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
req := httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
c.Request = req
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestCreate_DBError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnError(sql.ErrConnDone)
body := []byte(`{"cron_expr":"*/5 * * * *","prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreate_ReturnsNextRunAt(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
mock.ExpectQuery("INSERT INTO workspace_schedules").
WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1"))
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"do thing"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if resp["status"] != "created" {
t.Errorf("status=created: got %v", resp["status"])
}
if _, ok := resp["id"]; !ok {
t.Errorf("response missing id field")
}
if _, ok := resp["next_run_at"]; !ok {
t.Errorf("response missing next_run_at field")
}
}
// ─── Update ───────────────────────────────────────────────────────────────────
func TestUpdate_PartialUpdate_CRONChangeRecomputesNextRun(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
// 1. Lookup current cron + timezone.
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 * * * *", "UTC"))
// 2. UPDATE with new cron_expr but old timezone; next_run_at = new computed.
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), "*/5 * * * *", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 1))
body := []byte(`{"cron_expr":"*/5 * * * *"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestUpdate_PartialUpdate_TimezoneChangeRecomputesNextRun(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 * * * *", "UTC"))
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), "America/New_York", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 1))
body := []byte(`{"timezone":"America/New_York"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdate_NoScheduleMatch_Returns404(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
// body={} means CronExpr=nil AND Timezone=nil → handler skips the lookup
// and goes straight to UPDATE. Expect UPDATE with 0 rows affected → 404.
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader([]byte(`{}`)))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestUpdate_InvalidTimezone_Returns400(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 * * * *", "UTC"))
body := []byte(`{"timezone":"Mars/Olympus"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid timezone") {
t.Errorf("error should mention 'invalid timezone': %s", w.Body.String())
}
}
func TestUpdate_InvalidCronExpr_Returns400(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 * * * *", "UTC"))
body := []byte(`{"cron_expr":"[invalid"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdate_ScheduleNotFoundOnExec_Returns404(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
// No cron/timezone change → no lookup; goes straight to UPDATE.
// RowsAffected=0 means no matching row → 404.
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 0))
body := []byte(`{"name":"new name"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdate_DBError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnError(sql.ErrConnDone)
body := []byte(`{"name":"new name"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdate_PromptCRLFStripped(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
// No cron/timezone change → no lookup; UPDATE directly.
// The prompt arg must have \r stripped.
mock.ExpectExec(`UPDATE workspace_schedules SET`).
WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "line1\nline2", sqlmock.AnyArg(), sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 1))
body := []byte(`{"prompt":"line1\r\nline2"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/schedules/"+schedID, bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v — \\r must be stripped before UPDATE", err)
}
}
// ─── Delete ───────────────────────────────────────────────────────────────────
func TestDelete_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/schedules/"+schedID, nil)
handler.Delete(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "deleted") {
t.Errorf("response should contain 'deleted': %s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestDelete_NotFound_Returns404(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
// IDOR: schedule belongs to a different workspace → no rows deleted.
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnResult(sqlmock.NewResult(0, 0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/schedules/"+schedID, nil)
handler.Delete(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestDelete_DBError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/schedules/"+schedID, nil)
handler.Delete(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// ─── RunNow ───────────────────────────────────────────────────────────────────
func TestRunNow_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnRows(sqlmock.NewRows([]string{"prompt"}).AddRow("do the thing"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules/"+schedID+"/run", nil)
handler.RunNow(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if resp["status"] != "fired" {
t.Errorf("status=fired: got %v", resp["status"])
}
if resp["prompt"] != "do the thing" {
t.Errorf("prompt: got %v", resp["prompt"])
}
if resp["workspace_id"] != wsID {
t.Errorf("workspace_id: got %v", resp["workspace_id"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestRunNow_NotFound_Returns404(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules/"+schedID+"/run", nil)
handler.RunNow(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestRunNow_DBError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
WithArgs(schedID, wsID).
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/schedules/"+schedID+"/run", nil)
handler.RunNow(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// ─── History ─────────────────────────────────────────────────────────────────
func TestHistory_EmptyResult(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
cols := []string{"created_at", "duration_ms", "status", "error_detail", "request_body"}
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
WithArgs(wsID, schedID).
WillReturnRows(sqlmock.NewRows(cols))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("GET",
"/workspaces/"+wsID+"/schedules/"+schedID+"/history", nil)
handler.History(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var entries []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected empty history, got %d entries", len(entries))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}
func TestHistory_QueryError_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
WithArgs(wsID, schedID).
WillReturnError(errors.New("connection lost"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("GET",
"/workspaces/"+wsID+"/schedules/"+schedID+"/history", nil)
handler.History(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestHistory_MultipleEntries_ReverseOrder(t *testing.T) {
// Verifies the History handler correctly deserialises multiple rows and
// includes error_detail in the response (#152). sqlmock doesn't produce
// scan errors from nil pointer fields (the driver accepts nil for *int
// and *string columns), so we verify the happy multi-row path instead.
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewScheduleHandler()
wsID := "550e8400-e29b-41d4-a716-446655440000"
schedID := "11111111-1111-1111-1111-111111111111"
now := time.Now().UTC().Truncate(time.Second)
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
WithArgs(wsID, schedID).
WillReturnRows(sqlmock.NewRows([]string{"created_at", "duration_ms", "status", "error_detail", "request_body"}).
AddRow(now, 500, "ok", "", `{"schedule_id":"`+schedID+`"}`).
AddRow(now, 1200, "error", "HTTP 500 — OOM", `{"schedule_id":"`+schedID+`"}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: wsID},
{Key: "scheduleId", Value: schedID},
}
c.Request = httptest.NewRequest("GET",
"/workspaces/"+wsID+"/schedules/"+schedID+"/history", nil)
handler.History(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var entries []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
// error_detail must be populated for the failed run.
if entries[1]["error_detail"] != "HTTP 500 — OOM" {
t.Errorf("error_detail: got %v", entries[1]["error_detail"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sqlmock: %v", err)
}
}