@@ -23,6 +23,7 @@ import (
2323"github.com/coder/coder/v2/coderd/database/dbfake"
2424"github.com/coder/coder/v2/coderd/database/dbgen"
2525"github.com/coder/coder/v2/coderd/database/dbtime"
26+ "github.com/coder/coder/v2/coderd/httpapi"
2627"github.com/coder/coder/v2/coderd/notifications"
2728"github.com/coder/coder/v2/coderd/notifications/notificationstest"
2829"github.com/coder/coder/v2/coderd/util/slice"
@@ -738,6 +739,210 @@ func TestTasks(t *testing.T) {
738739require .Equal (t ,http .StatusBadGateway ,sdkErr .StatusCode ())
739740})
740741})
742+
743+ t .Run ("UpdateInput" ,func (t * testing.T ) {
744+ tests := []struct {
745+ name string
746+ disableProvisioner bool
747+ transition database.WorkspaceTransition
748+ cancelTransition bool
749+ deleteTask bool
750+ taskInput string
751+ wantStatus codersdk.TaskStatus
752+ wantErr string
753+ wantErrStatusCode int
754+ }{
755+ {
756+ name :"TaskStatusInitializing" ,
757+ // We want to disable the provisioner so that the task
758+ // never gets provisioned (ensuring it stays in Initializing).
759+ disableProvisioner :true ,
760+ taskInput :"Valid prompt" ,
761+ wantStatus :codersdk .TaskStatusInitializing ,
762+ wantErr :"Unable to update" ,
763+ wantErrStatusCode :http .StatusConflict ,
764+ },
765+ {
766+ name :"TaskStatusPaused" ,
767+ transition :database .WorkspaceTransitionStop ,
768+ taskInput :"Valid prompt" ,
769+ wantStatus :codersdk .TaskStatusPaused ,
770+ },
771+ {
772+ name :"TaskStatusError" ,
773+ transition :database .WorkspaceTransitionStart ,
774+ cancelTransition :true ,
775+ taskInput :"Valid prompt" ,
776+ wantStatus :codersdk .TaskStatusError ,
777+ wantErr :"Unable to update" ,
778+ wantErrStatusCode :http .StatusConflict ,
779+ },
780+ {
781+ name :"EmptyPrompt" ,
782+ transition :database .WorkspaceTransitionStop ,
783+ // We want to ensure an empty prompt is rejected.
784+ taskInput :"" ,
785+ wantStatus :codersdk .TaskStatusPaused ,
786+ wantErr :"Task input is required." ,
787+ wantErrStatusCode :http .StatusBadRequest ,
788+ },
789+ {
790+ name :"TaskDeleted" ,
791+ transition :database .WorkspaceTransitionStop ,
792+ deleteTask :true ,
793+ taskInput :"Valid prompt" ,
794+ wantErr :httpapi .ResourceNotFoundResponse .Message ,
795+ wantErrStatusCode :http .StatusNotFound ,
796+ },
797+ }
798+
799+ for _ ,tt := range tests {
800+ t .Run (tt .name ,func (t * testing.T ) {
801+ t .Parallel ()
802+
803+ client ,provisioner := coderdtest .NewWithProvisionerCloser (t ,& coderdtest.Options {IncludeProvisionerDaemon :true })
804+ user := coderdtest .CreateFirstUser (t ,client )
805+ ctx := testutil .Context (t ,testutil .WaitLong )
806+
807+ template := createAITemplate (t ,client ,user )
808+
809+ if tt .disableProvisioner {
810+ provisioner .Close ()
811+ }
812+
813+ // Given: We create a task
814+ exp := codersdk .NewExperimentalClient (client )
815+ task ,err := exp .CreateTask (ctx ,codersdk .Me , codersdk.CreateTaskRequest {
816+ TemplateVersionID :template .ActiveVersionID ,
817+ Input :"initial prompt" ,
818+ })
819+ require .NoError (t ,err )
820+ require .True (t ,task .WorkspaceID .Valid ,"task should have a workspace ID" )
821+
822+ if ! tt .disableProvisioner {
823+ // Given: The Task is running
824+ workspace ,err := client .Workspace (ctx ,task .WorkspaceID .UUID )
825+ require .NoError (t ,err )
826+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,workspace .LatestBuild .ID )
827+
828+ // Given: We transition the task's workspace
829+ build := coderdtest .CreateWorkspaceBuild (t ,client ,workspace ,tt .transition )
830+ if tt .cancelTransition {
831+ // Given: We cancel the workspace build
832+ err := client .CancelWorkspaceBuild (ctx ,build .ID , codersdk.CancelWorkspaceBuildParams {})
833+ require .NoError (t ,err )
834+
835+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,build .ID )
836+
837+ // Then: We expect it to be canceled
838+ build ,err = client .WorkspaceBuild (ctx ,build .ID )
839+ require .NoError (t ,err )
840+ require .Equal (t ,codersdk .WorkspaceStatusCanceled ,build .Status )
841+ }else {
842+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,build .ID )
843+ }
844+ }
845+
846+ if tt .deleteTask {
847+ err = exp .DeleteTask (ctx ,codersdk .Me ,task .ID )
848+ require .NoError (t ,err )
849+ }else {
850+ // Given: Task has expected status
851+ task ,err = exp .TaskByID (ctx ,task .ID )
852+ require .NoError (t ,err )
853+ require .Equal (t ,tt .wantStatus ,task .Status )
854+ }
855+
856+ // When: We attempt to update the task input
857+ err = exp .UpdateTaskInput (ctx ,task .OwnerName ,task .ID , codersdk.UpdateTaskInputRequest {
858+ Input :tt .taskInput ,
859+ })
860+ if tt .wantErr != "" {
861+ require .ErrorContains (t ,err ,tt .wantErr )
862+
863+ if tt .wantErrStatusCode != 0 {
864+ var apiErr * codersdk.Error
865+ require .ErrorAs (t ,err ,& apiErr )
866+ require .Equal (t ,tt .wantErrStatusCode ,apiErr .StatusCode ())
867+ }
868+
869+ if ! tt .deleteTask {
870+ // Then: We expect the input to **not** be updated
871+ task ,err = exp .TaskByID (ctx ,task .ID )
872+ require .NoError (t ,err )
873+ require .NotEqual (t ,tt .taskInput ,task .InitialPrompt )
874+ }
875+ }else {
876+ require .NoError (t ,err )
877+
878+ if ! tt .deleteTask {
879+ // Then: We expect the input to be updated
880+ task ,err = exp .TaskByID (ctx ,task .ID )
881+ require .NoError (t ,err )
882+ require .Equal (t ,tt .taskInput ,task .InitialPrompt )
883+ }
884+ }
885+ })
886+ }
887+
888+ t .Run ("NonExistentTask" ,func (t * testing.T ) {
889+ t .Parallel ()
890+
891+ client := coderdtest .New (t ,& coderdtest.Options {IncludeProvisionerDaemon :true })
892+ user := coderdtest .CreateFirstUser (t ,client )
893+ ctx := testutil .Context (t ,testutil .WaitShort )
894+
895+ exp := codersdk .NewExperimentalClient (client )
896+
897+ // Attempt to update prompt for non-existent task
898+ err := exp .UpdateTaskInput (ctx ,user .UserID .String (),uuid .New (), codersdk.UpdateTaskInputRequest {
899+ Input :"Should fail" ,
900+ })
901+ require .Error (t ,err )
902+ var apiErr * codersdk.Error
903+ require .ErrorAs (t ,err ,& apiErr )
904+ require .Equal (t ,http .StatusNotFound ,apiErr .StatusCode ())
905+ })
906+
907+ t .Run ("UnauthorizedUser" ,func (t * testing.T ) {
908+ t .Parallel ()
909+
910+ client := coderdtest .New (t ,& coderdtest.Options {IncludeProvisionerDaemon :true })
911+ user := coderdtest .CreateFirstUser (t ,client )
912+ anotherUser ,_ := coderdtest .CreateAnotherUser (t ,client ,user .OrganizationID )
913+ ctx := testutil .Context (t ,testutil .WaitLong )
914+
915+ template := createAITemplate (t ,client ,user )
916+
917+ // Create a task as the first user
918+ exp := codersdk .NewExperimentalClient (client )
919+ task ,err := exp .CreateTask (ctx ,codersdk .Me , codersdk.CreateTaskRequest {
920+ TemplateVersionID :template .ActiveVersionID ,
921+ Input :"initial prompt" ,
922+ })
923+ require .NoError (t ,err )
924+ require .True (t ,task .WorkspaceID .Valid )
925+
926+ // Wait for workspace to complete
927+ workspace ,err := client .Workspace (ctx ,task .WorkspaceID .UUID )
928+ require .NoError (t ,err )
929+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,workspace .LatestBuild .ID )
930+
931+ // Stop the workspace
932+ build := coderdtest .CreateWorkspaceBuild (t ,client ,workspace ,database .WorkspaceTransitionStop )
933+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,build .ID )
934+
935+ // Attempt to update prompt as another user should fail with 404 Not Found
936+ otherExp := codersdk .NewExperimentalClient (anotherUser )
937+ err = otherExp .UpdateTaskInput (ctx ,task .OwnerName ,task .ID , codersdk.UpdateTaskInputRequest {
938+ Input :"Should fail - unauthorized" ,
939+ })
940+ require .Error (t ,err )
941+ var apiErr * codersdk.Error
942+ require .ErrorAs (t ,err ,& apiErr )
943+ require .Equal (t ,http .StatusNotFound ,apiErr .StatusCode ())
944+ })
945+ })
741946}
742947
743948func TestTasksCreate (t * testing.T ) {