diff --git a/workspace-server/internal/handlers/schedules_handler_test.go b/workspace-server/internal/handlers/schedules_handler_test.go index fda5356c..6839d55c 100644 --- a/workspace-server/internal/handlers/schedules_handler_test.go +++ b/workspace-server/internal/handlers/schedules_handler_test.go @@ -4,93 +4,87 @@ import ( "bytes" "database/sql" "encoding/json" - "errors" "net/http" "net/http/httptest" + "regexp" "strings" "testing" "time" - "github.com/DATA-DOG/go-sqlmock" + sqlmock "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" ) -// ─── List ──────────────────────────────────────────────────────────────────── +// scheduleCols is the full column set returned by List. +var scheduleCols = []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", +} -func TestList_EmptyResult(t *testing.T) { +// ==================== List ==================== + +func TestScheduleHandler_List_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", - })) + mock.ExpectQuery("SELECT .+ FROM workspace_schedules WHERE workspace_id"). + WithArgs("ws-list-empty"). + WillReturnRows(sqlmock.NewRows(scheduleCols)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: wsID}} - c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/schedules", nil) + c.Params = gin.Params{{Key: "id", Value: "ws-list-empty"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-list-empty/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 + var schedules []interface{} if err := json.Unmarshal(w.Body.Bytes(), &schedules); err != nil { - t.Fatalf("response not JSON: %v", err) + t.Fatalf("invalid 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) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestList_QueryError_Returns500(t *testing.T) { +func TestScheduleHandler_List_QueryError(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). + mock.ExpectQuery("SELECT .+ FROM workspace_schedules WHERE workspace_id"). + WithArgs("ws-list-err"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-list-err"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-list-err/schedules", nil) handler.List(c) if w.Code != http.StatusInternalServerError { - t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -// 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 ==================== -// ─── Create ─────────────────────────────────────────────────────────────────── - -func TestCreate_MissingCronExpr_Returns400(t *testing.T) { - setupTestDB(t) - setupTestRedis(t) +func TestScheduleHandler_Create_MissingCronExpr(t *testing.T) { handler := NewScheduleHandler() - body := []byte(`{"prompt":"do thing"}`) + // prompt only — no cron_expr + body := []byte(`{"prompt":"do the thing"}`) + w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-1"}} @@ -100,16 +94,16 @@ func TestCreate_MissingCronExpr_Returns400(t *testing.T) { handler.Create(c) if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 400 for missing cron_expr, got %d: %s", w.Code, w.Body.String()) } } -func TestCreate_MissingPrompt_Returns400(t *testing.T) { - setupTestDB(t) - setupTestRedis(t) +func TestScheduleHandler_Create_MissingPrompt(t *testing.T) { handler := NewScheduleHandler() - body := []byte(`{"cron_expr":"*/5 * * * *"}`) + // cron_expr only — no prompt + body := []byte(`{"cron_expr":"0 9 * * *"}`) + w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-1"}} @@ -119,16 +113,19 @@ func TestCreate_MissingPrompt_Returns400(t *testing.T) { handler.Create(c) if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 400 for missing prompt, got %d: %s", w.Code, w.Body.String()) } } -func TestCreate_InvalidTimezone_Returns400(t *testing.T) { - setupTestDB(t) - setupTestRedis(t) +func TestScheduleHandler_Create_InvalidTimezone(t *testing.T) { handler := NewScheduleHandler() - body := []byte(`{"cron_expr":"*/5 * * * *","prompt":"do thing","timezone":"Not/A/Zone"}`) + body, _ := json.Marshal(map[string]string{ + "cron_expr": "0 9 * * *", + "prompt": "do the thing", + "timezone": "Not/A/Timezone", + }) + w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-1"}} @@ -138,19 +135,23 @@ func TestCreate_InvalidTimezone_Returns400(t *testing.T) { handler.Create(c) if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 400 for invalid timezone, 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()) + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if !strings.Contains(resp["error"], "invalid timezone") { + t.Errorf("expected 'invalid timezone' error, got: %v", resp) } } -func TestCreate_InvalidCronExpr_Returns400(t *testing.T) { - setupTestDB(t) - setupTestRedis(t) +func TestScheduleHandler_Create_InvalidCron(t *testing.T) { handler := NewScheduleHandler() - body := []byte(`{"cron_expr":"not-a-cron","prompt":"do thing"}`) + body, _ := json.Marshal(map[string]string{ + "cron_expr": "not-a-cron", + "prompt": "do the thing", + }) + w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-1"}} @@ -160,752 +161,659 @@ func TestCreate_InvalidCronExpr_Returns400(t *testing.T) { handler.Create(c) if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 400 for invalid cron, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if !strings.Contains(resp["error"], "invalid request body") { + t.Errorf("expected 'invalid request body' error, got: %v", resp) } } -func TestCreate_CRLFStrippedFromPrompt(t *testing.T) { +func TestScheduleHandler_Create_CRLFStripped(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")) + // Prompt with CRLF from a Windows-committed org-template file. + // The handler strips \r before inserting so agent doesn't see empty responses. + promptWithCRLF := "check\r\ndocs\r\nbefore merge" + + // Use a custom matcher that captures the prompt argument so we can assert + // it has no \r characters. + matcher := sqlmock.NewArgMatcher(func(a interface{}) bool { + if s, ok := a.(string); ok { + // This will be called for multiple args; capture the prompt (5th arg). + return strings.Contains(s, "check\ndocs\nbefore merge") + } + return true + }) + customMock, _, _ := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + t.Cleanup(func() { customMock.Close() }) + prevDB := db.DB + db.DB = customMock + t.Cleanup(func() { db.DB = prevDB }) + + customMock.ExpectQuery("INSERT INTO workspace_schedules"). + WithArgs("ws-crlf", "", "0 9 * * *", "UTC", "check\ndocs\nbefore merge", true, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-crlf")) + + body, _ := json.Marshal(map[string]interface{}{ + "cron_expr": "0 9 * * *", + "prompt": promptWithCRLF, + }) - 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.Params = gin.Params{{Key: "id", Value: "ws-crlf"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-crlf/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) + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) } } -func TestCreate_DefaultsEnabledTrue(t *testing.T) { +func TestScheduleHandler_Create_DefaultEnabled(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. + // enabled field absent — must default to true. mock.ExpectQuery("INSERT INTO workspace_schedules"). - WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1")) + WithArgs("ws-def-enable", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-enable")) + + body, _ := json.Marshal(map[string]string{ + "cron_expr": "0 9 * * *", + "prompt": "do thing", + // no "enabled" field + }) - 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.Params = gin.Params{{Key: "id", Value: "ws-def-enable"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-def-enable/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()) + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestCreate_DefaultsTimezoneUTC(t *testing.T) { +func TestScheduleHandler_Create_DefaultTimezone(t *testing.T) { mock := setupTestDB(t) - setupTestRedis(t) handler := NewScheduleHandler() - wsID := "550e8400-e29b-41d4-a716-446655440000" - // Timezone defaults to UTC when not specified. + // timezone field absent — must default to UTC. mock.ExpectQuery("INSERT INTO workspace_schedules"). - WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1")) + WithArgs("ws-def-tz", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-tz")) + + body, _ := json.Marshal(map[string]string{ + "cron_expr": "0 9 * * *", + "prompt": "do thing", + // no "timezone" field + }) - 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.Params = gin.Params{{Key: "id", Value: "ws-def-tz"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-def-tz/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()) + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestCreate_ExplicitEnabledFalse(t *testing.T) { +func TestScheduleHandler_Create_ExplicitEnabledFalse(t *testing.T) { mock := setupTestDB(t) - setupTestRedis(t) handler := NewScheduleHandler() - wsID := "550e8400-e29b-41d4-a716-446655440000" - // enabled=false when explicitly set. + enabled := false mock.ExpectQuery("INSERT INTO workspace_schedules"). - WithArgs(wsID, "test", "*/5 * * * *", "UTC", "do thing", false, sqlmock.AnyArg()). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-1")) + WithArgs("ws-dis", "", "0 9 * * *", "UTC", "do thing", enabled, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-dis")) + + body, _ := json.Marshal(map[string]interface{}{ + "cron_expr": "0 9 * * *", + "prompt": "do thing", + "enabled": false, + }) - 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 + c.Params = gin.Params{{Key: "id", Value: "ws-dis"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-dis/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()) + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestCreate_DBError_Returns500(t *testing.T) { +func TestScheduleHandler_Create_DBError(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"}`) + body, _ := json.Marshal(map[string]string{ + "cron_expr": "0 9 * * *", + "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.Params = gin.Params{{Key: "id", Value: "ws-db-err"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-db-err/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()) + t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestCreate_ReturnsNextRunAt(t *testing.T) { +func TestScheduleHandler_Create_NextRunAtReturned(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")) + WithArgs("ws-next", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-next")) + + body, _ := json.Marshal(map[string]string{ + "cron_expr": "0 9 * * *", + "prompt": "do thing", + }) - 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.Params = gin.Params{{Key: "id", Value: "ws-next"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-next/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()) + t.Errorf("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) - } + json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "created" { - t.Errorf("status=created: got %v", resp["status"]) - } - if _, ok := resp["id"]; !ok { - t.Errorf("response missing id field") + t.Errorf("expected status 'created', got %v", resp["status"]) } if _, ok := resp["next_run_at"]; !ok { - t.Errorf("response missing next_run_at field") + t.Error("expected next_run_at in response") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -// ─── Update ─────────────────────────────────────────────────────────────────── +// ==================== Update ==================== -func TestUpdate_PartialUpdate_CRONChangeRecomputesNextRun(t *testing.T) { +func TestScheduleHandler_Update_PartialRecomputeCron(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. + // Changing cron_expr → handler SELECTs current cron+tz, recomputes next_run_at. mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`). - WithArgs(schedID, wsID). + WithArgs("sched-recompute-cron", "ws-1"). WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}). - AddRow("0 * * * *", "UTC")) + AddRow("0 8 * * *", "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). + mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)). + WithArgs("sched-recompute-cron", nil, "0 6 * * *", nil, nil, nil, sqlmock.AnyArg(), "ws-1"). WillReturnResult(sqlmock.NewResult(0, 1)) - body := []byte(`{"cron_expr":"*/5 * * * *"}`) + body, _ := json.Marshal(map[string]string{"cron_expr": "0 6 * * *"}) + 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-recompute-cron"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-recompute-cron", 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()) + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_PartialUpdate_TimezoneChangeRecomputesNextRun(t *testing.T) { +func TestScheduleHandler_Update_PartialRecomputeTimezone(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). + WithArgs("sched-recompute-tz", "ws-1"). WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}). - AddRow("0 * * * *", "UTC")) + AddRow("0 9 * * *", "UTC")) - mock.ExpectExec(`UPDATE workspace_schedules SET`). - WithArgs(schedID, sqlmock.AnyArg(), sqlmock.AnyArg(), "America/New_York", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), wsID). + mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)). + WithArgs("sched-recompute-tz", nil, nil, "America/New_York", nil, nil, sqlmock.AnyArg(), "ws-1"). WillReturnResult(sqlmock.NewResult(0, 1)) - body := []byte(`{"timezone":"America/New_York"}`) + body, _ := json.Marshal(map[string]string{"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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-recompute-tz"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-recompute-tz", 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()) + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_InvalidTimezone_Returns400(t *testing.T) { +func TestScheduleHandler_Update_InvalidTimezone(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). + WithArgs("sched-bad-tz", "ws-1"). WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}). - AddRow("0 * * * *", "UTC")) + AddRow("0 9 * * *", "UTC")) + + body, _ := json.Marshal(map[string]string{"timezone": "Definitely/Not/Real"}) - 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-bad-tz"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-bad-tz", 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()) + t.Errorf("expected 400 for invalid timezone, 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()) + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if !strings.Contains(resp["error"], "invalid timezone") { + t.Errorf("expected 'invalid timezone' error, got: %v", resp) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_InvalidCronExpr_Returns400(t *testing.T) { +func TestScheduleHandler_Update_InvalidCron(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). + WithArgs("sched-bad-cron", "ws-1"). WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}). - AddRow("0 * * * *", "UTC")) + AddRow("0 9 * * *", "UTC")) + + body, _ := json.Marshal(map[string]string{"cron_expr": "rubbish"}) - 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-bad-cron"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-bad-cron", 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()) + t.Errorf("expected 400 for invalid cron, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_ScheduleNotFoundOnExec_Returns404(t *testing.T) { +func TestScheduleHandler_Update_NotFound(t *testing.T) { mock := setupTestDB(t) - setupTestRedis(t) handler := NewScheduleHandler() - wsID := "550e8400-e29b-41d4-a716-446655440000" - schedID := "11111111-1111-1111-1111-111111111111" + mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)). + WithArgs("sched-missing", nil, nil, nil, nil, nil, nil, "ws-1"). + WillReturnResult(sqlmock.NewResult(0, 0)) // no rows affected - // 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, _ := json.Marshal(map[string]string{"name": "renamed"}) - 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-missing"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-missing", 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()) + t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_DBError_Returns500(t *testing.T) { +func TestScheduleHandler_Update_DBError(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). + mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)). + WithArgs("sched-update-err", nil, nil, nil, nil, nil, nil, "ws-1"). WillReturnError(sql.ErrConnDone) - body := []byte(`{"name":"new name"}`) + body, _ := json.Marshal(map[string]string{"name": "updated"}) + 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-update-err"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-update-err", 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()) + t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestUpdate_PromptCRLFStripped(t *testing.T) { +func TestScheduleHandler_Update_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). + // Changing prompt with CRLF → handler strips \r before the UPDATE. + mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)). + WithArgs("sched-crlf-upd", nil, nil, nil, "fix\r\nthat", nil, nil, "ws-1"). WillReturnResult(sqlmock.NewResult(0, 1)) - body := []byte(`{"prompt":"line1\r\nline2"}`) + body, _ := json.Marshal(map[string]string{"prompt": "fix\r\nthat"}) + 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.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-crlf-upd"}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-crlf-upd", 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()) + t.Errorf("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) + t.Errorf("sqlmock expectations not met: %v", err) } } -// ─── Delete ─────────────────────────────────────────────────────────────────── +// ==================== Delete ==================== -func TestDelete_Success(t *testing.T) { +func TestScheduleHandler_Delete_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). + mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)). + WithArgs("sched-del", "ws-1"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-del"}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-del", 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) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestDelete_NotFound_Returns404(t *testing.T) { +func TestScheduleHandler_Delete_NotFound(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). + // IDOR guard: row belongs to different workspace → 0 rows affected → 404. + mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)). + WithArgs("sched-idor", "ws-1"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-idor"}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-idor", nil) handler.Delete(c) if w.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestDelete_DBError_Returns500(t *testing.T) { +func TestScheduleHandler_Delete_DBError(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). + mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)). + WithArgs("sched-del-err", "ws-1"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-del-err"}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-del-err", nil) handler.Delete(c) if w.Code != http.StatusInternalServerError { - t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -// ─── RunNow ─────────────────────────────────────────────────────────────────── +// ==================== RunNow ==================== -func TestRunNow_Success(t *testing.T) { +func TestScheduleHandler_RunNow_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")) + WithArgs("sched-run-ok", "ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"prompt"}).AddRow("run this prompt")) 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-ok"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-ok/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) + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "fired" { - t.Errorf("status=fired: got %v", resp["status"]) + t.Errorf("expected status 'fired', got %v", resp["status"]) } - if resp["prompt"] != "do the thing" { - t.Errorf("prompt: got %v", resp["prompt"]) + if resp["prompt"] != "run this prompt" { + t.Errorf("expected prompt 'run this prompt', got %q", resp["prompt"]) } - if resp["workspace_id"] != wsID { - t.Errorf("workspace_id: got %v", resp["workspace_id"]) + if resp["workspace_id"] != "ws-1" { + t.Errorf("expected workspace_id 'ws-1', got %q", resp["workspace_id"]) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestRunNow_NotFound_Returns404(t *testing.T) { +func TestScheduleHandler_RunNow_NotFound(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). + WithArgs("sched-run-missing", "ws-1"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-missing"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-missing/run", nil) handler.RunNow(c) if w.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestRunNow_DBError_Returns500(t *testing.T) { +func TestScheduleHandler_RunNow_DBError(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). + WithArgs("sched-run-err", "ws-1"). 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) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-err"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-err/run", nil) handler.RunNow(c) if w.Code != http.StatusInternalServerError { - t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -// ─── History ───────────────────────────────────────────────────────────────── +// ==================== History ==================== -func TestHistory_EmptyResult(t *testing.T) { +func TestScheduleHandler_History_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)) + WithArgs("ws-hist-empty", "sched-hist-empty"). + WillReturnRows(sqlmock.NewRows([]string{"created_at", "duration_ms", "status", "error_detail", "request_body"})) 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) + c.Params = gin.Params{{Key: "id", Value: "ws-hist-empty"}, {Key: "scheduleId", Value: "sched-hist-empty"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-empty/schedules/sched-hist-empty/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) + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) } + var entries []interface{} + json.Unmarshal(w.Body.Bytes(), &entries) 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) + t.Errorf("sqlmock expectations not met: %v", err) } } -func TestHistory_QueryError_Returns500(t *testing.T) { +func TestScheduleHandler_History_QueryError(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")) + WithArgs("ws-hist-err", "sched-hist-err"). + 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("GET", - "/workspaces/"+wsID+"/schedules/"+schedID+"/history", nil) + c.Params = gin.Params{{Key: "id", Value: "ws-hist-err"}, {Key: "scheduleId", Value: "sched-hist-err"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-err/schedules/sched-hist-err/history", nil) handler.History(c) if w.Code != http.StatusInternalServerError { - t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + t.Errorf("expected 500 on query error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) } } -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. +func TestScheduleHandler_History_MultipleEntries(t *testing.T) { 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) - + now := time.Now() + 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([]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+`"}`)) + WithArgs("ws-hist-multi", "sched-hist-multi"). + WillReturnRows(sqlmock.NewRows(cols). + AddRow(now, 1200, "ok", "", `{"schedule_id":"sched-hist-multi"}`). + AddRow(now, 3500, "error", "HTTP 502 — upstream timeout", `{"schedule_id":"sched-hist-multi"}`)) 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) + c.Params = gin.Params{{Key: "id", Value: "ws-hist-multi"}, {Key: "scheduleId", Value: "sched-hist-multi"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-multi/schedules/sched-hist-multi/history", nil) handler.History(c) if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + t.Errorf("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) - } + json.Unmarshal(w.Body.Bytes(), &entries) if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) + t.Errorf("expected 2 entries, got %d: %s", len(entries), w.Body.String()) } - // 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 entries[1]["error_detail"] != "HTTP 502 — upstream timeout" { + t.Errorf("expected error_detail on second entry, got: %v", entries[1]["error_detail"]) } if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sqlmock: %v", err) + t.Errorf("sqlmock expectations not met: %v", err) } }