diff --git a/workspace-server/internal/handlers/schedules_handler_test.go b/workspace-server/internal/handlers/schedules_handler_test.go new file mode 100644 index 00000000..fda5356c --- /dev/null +++ b/workspace-server/internal/handlers/schedules_handler_test.go @@ -0,0 +1,911 @@ +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) + } +}