diff --git a/ee/lib/api/ai/duo_workflows/workflows.rb b/ee/lib/api/ai/duo_workflows/workflows.rb index e2fd52f37eb4c2f3364fb710d7a8c3f3881f28f1..719c8bda3fa5bc3e2a74eb48bdf4e38ddad4bd46 100644 --- a/ee/lib/api/ai/duo_workflows/workflows.rb +++ b/ee/lib/api/ai/duo_workflows/workflows.rb @@ -11,8 +11,6 @@ class Workflows < ::API::Base before { authenticate! } - allow_access_with_scope :ai_workflows - helpers do def find_workflow!(id) workflow = ::Ai::DuoWorkflows::Workflow.for_user_with_id!(current_user.id, id) @@ -21,10 +19,6 @@ def find_workflow!(id) forbidden! end - def find_event!(workflow, id) - workflow.events.find(id) - end - def authorize_run_workflows!(project) return if can?(current_user, :duo_workflow, project) @@ -57,15 +51,6 @@ def create_workflow_params declared_params(include_missing: false) end - def render_response(response) - if response.success? - status :ok - response.payload - else - render_api_error!(response.message, response.reason) - end - end - params :workflow_params do requires :project_id, type: String, desc: 'The ID or path of the workflow project', documentation: { example: '1' } @@ -148,99 +133,6 @@ def render_response(response) present workflow, with: ::API::Entities::Ai::DuoWorkflows::Workflow end - - desc 'Updates the workflow status' do - success code: 200 - end - params do - requires :id, type: Integer, desc: 'The ID of the workflow', documentation: { example: 1 } - requires :status_event, type: String, desc: 'The status event', - documentation: { example: 'finish' } - end - patch '/:id' do - workflow = find_workflow!(params[:id]) - forbidden! unless current_user.can?(:update_duo_workflow, workflow) - - service = ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new( - workflow: workflow, - status_event: params[:status_event], - current_user: current_user - ) - - render_response(service.execute) - end - - params do - requires :id, type: Integer, desc: 'The ID of the workflow' - requires :thread_ts, type: String, desc: 'The thread ts' - optional :parent_ts, type: String, desc: 'The parent ts' - requires :checkpoint, type: Hash, desc: "Checkpoint content" - requires :metadata, type: Hash, desc: "Checkpoint metadata" - end - post '/:id/checkpoints' do - workflow = find_workflow!(params[:id]) - checkpoint_params = declared_params(include_missing: false).except(:id) - service = ::Ai::DuoWorkflows::CreateCheckpointService.new(project: workflow.project, - workflow: workflow, params: checkpoint_params) - result = service.execute - - bad_request!(result[:message]) if result[:status] == :error - - present result[:checkpoint], with: ::API::Entities::Ai::DuoWorkflows::Checkpoint - end - - get '/:id/checkpoints' do - workflow = find_workflow!(params[:id]) - checkpoints = workflow.checkpoints.order(thread_ts: :desc) # rubocop:disable CodeReuse/ActiveRecord -- adding scope for order is no clearer - present paginate(checkpoints), with: ::API::Entities::Ai::DuoWorkflows::Checkpoint - end - - params do - requires :id, type: Integer, desc: 'The ID of the workflow' - requires :event_type, type: String, values: %w[response message pause stop resume], - desc: 'The type of event' - requires :message, type: String, desc: "Message from the human" - end - post '/:id/events' do - workflow = find_workflow!(params[:id]) - event_params = declared_params(include_missing: false).except(:id) - service = ::Ai::DuoWorkflows::CreateEventService.new( - project: workflow.project, - workflow: workflow, - params: event_params.merge(event_status: :queued) - ) - result = service.execute - - bad_request!(result[:message]) if result[:status] == :error - - present result[:event], with: ::API::Entities::Ai::DuoWorkflows::Event - end - - get '/:id/events' do - workflow = find_workflow!(params[:id]) - events = workflow.events.queued - present paginate(events), with: ::API::Entities::Ai::DuoWorkflows::Event - end - - params do - requires :id, type: Integer, desc: 'The ID of the workflow' - requires :event_id, type: Integer, desc: 'The ID of the event' - requires :event_status, type: String, values: %w[queued delivered], desc: 'The status of the event' - end - put '/:id/events/:event_id' do - workflow = find_workflow!(params[:id]) - event = find_event!(workflow, params[:event_id]) - event_params = declared_params(include_missing: false).except(:id, :event_id) - service = ::Ai::DuoWorkflows::UpdateEventService.new( - event: event, - params: event_params - ) - result = service.execute - - bad_request!(result[:message]) if result[:status] == :error - - present result[:event], with: ::API::Entities::Ai::DuoWorkflows::Event - end end end end diff --git a/ee/lib/api/ai/duo_workflows/workflows_internal.rb b/ee/lib/api/ai/duo_workflows/workflows_internal.rb new file mode 100644 index 0000000000000000000000000000000000000000..94dd13889aa186eb29b0820f154295f58af9cbb1 --- /dev/null +++ b/ee/lib/api/ai/duo_workflows/workflows_internal.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +module API + module Ai + module DuoWorkflows + # This API is intended to be consumed by a running Duo Workflow using + # the ai_workflows scope token. These are requests coming from Duo Workflow + # Service and Duo Workflow Executor. We should not add any more requests to + # this API than needed by those 2 components. Otherwise add to + # `API::Ai::DuoWorkflows::Workflows`. + class WorkflowsInternal < ::API::Base + include PaginationParams + include APIGuard + + allow_access_with_scope :ai_workflows + + feature_category :duo_workflow + + before { authenticate! } + + helpers do + def find_workflow!(id) + workflow = ::Ai::DuoWorkflows::Workflow.for_user_with_id!(current_user.id, id) + return workflow if current_user.can?(:read_duo_workflow, workflow) + + forbidden! + end + + def find_event!(workflow, id) + workflow.events.find(id) + end + + def render_response(response) + if response.success? + status :ok + response.payload + else + render_api_error!(response.message, response.reason) + end + end + end + + namespace :ai do + namespace :duo_workflows do + namespace :workflows do + namespace '/:id' do + desc 'Updates the workflow status' do + success code: 200 + end + params do + requires :id, type: Integer, desc: 'The ID of the workflow', documentation: { example: 1 } + requires :status_event, type: String, desc: 'The status event', + documentation: { example: 'finish' } + end + patch do + workflow = find_workflow!(params[:id]) + forbidden! unless current_user.can?(:update_duo_workflow, workflow) + + service = ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new( + workflow: workflow, + status_event: params[:status_event], + current_user: current_user + ) + + render_response(service.execute) + end + + namespace :checkpoints do + params do + requires :id, type: Integer, desc: 'The ID of the workflow' + requires :thread_ts, type: String, desc: 'The thread ts' + optional :parent_ts, type: String, desc: 'The parent ts' + requires :checkpoint, type: Hash, desc: "Checkpoint content" + requires :metadata, type: Hash, desc: "Checkpoint metadata" + end + post do + workflow = find_workflow!(params[:id]) + checkpoint_params = declared_params(include_missing: false).except(:id) + service = ::Ai::DuoWorkflows::CreateCheckpointService.new(project: workflow.project, + workflow: workflow, params: checkpoint_params) + result = service.execute + + bad_request!(result[:message]) if result[:status] == :error + + present result[:checkpoint], with: ::API::Entities::Ai::DuoWorkflows::Checkpoint + end + + get do + workflow = find_workflow!(params[:id]) + checkpoints = workflow.checkpoints.order(thread_ts: :desc) # rubocop:disable CodeReuse/ActiveRecord -- adding scope for order is no clearer + present paginate(checkpoints), with: ::API::Entities::Ai::DuoWorkflows::Checkpoint + end + end + + namespace :events do + params do + requires :id, type: Integer, desc: 'The ID of the workflow' + requires :event_type, type: String, values: %w[response message pause stop resume], + desc: 'The type of event' + requires :message, type: String, desc: "Message from the human" + end + post do + workflow = find_workflow!(params[:id]) + event_params = declared_params(include_missing: false).except(:id) + service = ::Ai::DuoWorkflows::CreateEventService.new( + project: workflow.project, + workflow: workflow, + params: event_params.merge(event_status: :queued) + ) + result = service.execute + + bad_request!(result[:message]) if result[:status] == :error + + present result[:event], with: ::API::Entities::Ai::DuoWorkflows::Event + end + + get do + workflow = find_workflow!(params[:id]) + events = workflow.events.queued + present paginate(events), with: ::API::Entities::Ai::DuoWorkflows::Event + end + + params do + requires :id, type: Integer, desc: 'The ID of the workflow' + requires :event_id, type: Integer, desc: 'The ID of the event' + requires :event_status, type: String, values: %w[queued delivered], desc: 'The status of the event' + end + put '/:event_id' do + workflow = find_workflow!(params[:id]) + event = find_event!(workflow, params[:event_id]) + event_params = declared_params(include_missing: false).except(:id, :event_id) + service = ::Ai::DuoWorkflows::UpdateEventService.new( + event: event, + params: event_params + ) + result = service.execute + + bad_request!(result[:message]) if result[:status] == :error + + present result[:event], with: ::API::Entities::Ai::DuoWorkflows::Event + end + end + end + end + end + end + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index fd071c5d586b3a5db035cd13788cb86b76712c5f..4bfd8d2cfcb1149e036e1f15c576e5a35e1b794f 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -71,6 +71,7 @@ module API mount ::API::GroupServiceAccounts mount ::API::Ai::Llm::GitCommand mount ::API::Ai::DuoWorkflows::Workflows + mount ::API::Ai::DuoWorkflows::WorkflowsInternal mount ::API::CodeSuggestions mount ::API::Chat mount ::API::DuoCodeReview diff --git a/ee/spec/requests/api/ai/duo_workflows/workflows_internal_spec.rb b/ee/spec/requests/api/ai/duo_workflows/workflows_internal_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..84caa07704df710ff188cb05696dfca09d6ad09d --- /dev/null +++ b/ee/spec/requests/api/ai/duo_workflows/workflows_internal_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Ai::DuoWorkflows::WorkflowsInternal, feature_category: :duo_workflow do + include HttpBasicAuthHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:user) { create(:user, maintainer_of: project) } + let_it_be(:workflow) { create(:duo_workflows_workflow, user: user, project: project) } + let_it_be(:duo_workflow_service_url) { 'duo-workflow-service.example.com:50052' } + let_it_be(:oauth_token) { create(:oauth_access_token, user: user, scopes: [:ai_workflows]) } + + before do + allow(::Gitlab::Llm::StageCheck).to receive(:available?).with(project, :duo_workflow).and_return(true) + end + + describe 'POST /ai/duo_workflows/workflows/:id/checkpoints' do + let(:current_time) { Time.current } + let(:thread_ts) { current_time.to_s } + let(:later_thread_ts) { (current_time + 1.second).to_s } + let(:parent_ts) { (current_time - 1.second).to_s } + let(:checkpoint) { { key: 'value' } } + let(:metadata) { { key: 'value' } } + let(:params) { { thread_ts: thread_ts, checkpoint: checkpoint, parent_ts: parent_ts, metadata: metadata } } + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/checkpoints" } + + it 'allows creating multiple checkpoints for a workflow' do + expect do + post api(path, user), params: params + expect(response).to have_gitlab_http_status(:created) + + post api(path, user), params: params.merge(thread_ts: later_thread_ts, parent_ts: thread_ts) + expect(response).to have_gitlab_http_status(:created) + end.to change { workflow.reload.checkpoints.count }.by(2) + + expect(json_response['id']).to eq(Ai::DuoWorkflows::Checkpoint.last.id) + end + + context 'when authenticated with a token that has the ai_workflows scope' do + it 'is successful' do + post api(path, oauth_access_token: oauth_token), + params: params.merge(thread_ts: later_thread_ts, parent_ts: thread_ts) + + expect(response).to have_gitlab_http_status(:created) + end + end + + it 'fails if the thread_ts is an empty string' do + post api(path, user), params: params.merge(thread_ts: '') + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to include("can't be blank") + end + end + + describe 'GET /ai/duo_workflows/workflows/:id/checkpoints' do + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/checkpoints" } + + it 'returns the checkpoints in descending order of thread_ts' do + checkpoint1 = create(:duo_workflows_checkpoint, workflow: workflow) + checkpoint2 = create(:duo_workflows_checkpoint, workflow: workflow) + workflow.checkpoints << checkpoint1 + workflow.checkpoints << checkpoint2 + + get api(path, user) + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq([checkpoint2.id, checkpoint1.id]) + expect(json_response.pluck('thread_ts')).to eq([checkpoint2.thread_ts, checkpoint1.thread_ts]) + expect(json_response.pluck('parent_ts')).to eq([checkpoint2.parent_ts, checkpoint1.parent_ts]) + expect(json_response[0]).to have_key('checkpoint') + expect(json_response[0]).to have_key('metadata') + end + end + + describe 'POST /ai/duo_workflows/workflows/:id/events' do + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events" } + let(:params) do + { + event_type: 'message', + message: 'Hello, World!' + } + end + + context 'when success' do + it 'creates a new event' do + expect do + post api(path, user), params: params + expect(response).to have_gitlab_http_status(:created) + end.to change { workflow.events.count }.by(1) + expect(json_response['id']).to eq(Ai::DuoWorkflows::Event.last.id) + expect(json_response['event_type']).to eq('message') + expect(json_response['message']).to eq('Hello, World!') + expect(json_response['event_status']).to eq('queued') + end + + context 'when authenticated with a token that has the ai_workflows scope' do + it 'is successful' do + expect do + post api(path, oauth_access_token: oauth_token), params: params + expect(response).to have_gitlab_http_status(:created) + end.to change { workflow.events.count }.by(1) + end + end + end + + context 'when required parameters are missing' do + it 'returns bad request when event_type is missing' do + post api(path, user), params: params.except(:event_type) + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include("event_type is missing") + end + + it 'returns bad request when message is missing' do + post api(path, user), params: params.except(:message) + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include("message is missing") + end + end + + context 'when invalid event_type is provided' do + it 'returns bad request' do + post api(path, user), params: params.merge(event_type: 'invalid_event_type') + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include("event_type does not have a valid value") + end + end + + context 'with a workflow belonging to a different user' do + let(:workflow) { create(:duo_workflows_workflow) } + + it 'returns 404' do + post api(path, user), params: params + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET /ai/duo_workflows/workflows/:id/events' do + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events" } + + it 'returns queued events for the workflow' do + event1 = create(:duo_workflows_event, workflow: workflow, event_status: :queued) + event2 = create(:duo_workflows_event, workflow: workflow, event_status: :queued) + + get api(path, user) + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.size).to eq(2) + # rubocop:disable Rails/Pluck -- json_response is an array of hashes, we can't use pluck + expect(json_response.map { |e| e['id'] }).to contain_exactly(event1.id, event2.id) + expect(json_response.map { |e| e['event_status'] }).to all(eq('queued')) + # rubocop:enable Rails/Pluck + end + + it 'returns empty array if no queued events' do + create(:duo_workflows_event, workflow: workflow, event_status: :delivered) + + get api(path, user) + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_empty + end + + context 'with a workflow belonging to a different user' do + let(:workflow) { create(:duo_workflows_workflow) } + + it 'returns 404' do + get api(path, user) + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'PUT /ai/duo_workflows/workflows/:id/events/:event_id' do + let(:event) { create(:duo_workflows_event, workflow: workflow, event_status: :queued) } + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events/#{event.id}" } + let(:params) { { event_status: 'delivered' } } + + context 'when success' do + it 'updates the event status' do + put api(path, user), params: params + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(event.id) + expect(json_response['event_status']).to eq('delivered') + expect(event.reload.event_status).to eq('delivered') + end + + context 'when authenticated with a token that has the ai_workflows scope' do + it 'is successful' do + put api(path, oauth_access_token: oauth_token), params: params + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when invalid event_status is provided' do + it 'returns bad request' do + put api(path, user), params: { event_status: 'InvalidStatus' } + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include("event_status does not have a valid value") + end + end + + context 'when the event does not exist' do + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events/0" } + + it 'returns 404' do + put api(path, user), params: params + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with a workflow belonging to a different user' do + let(:workflow) { create(:duo_workflows_workflow) } + + it 'returns 404' do + put api(path, user), params: params + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with an event belonging to a different workflow' do + let(:other_workflow) { create(:duo_workflows_workflow, user: user, project: project) } + let(:event) { create(:duo_workflows_event, workflow: other_workflow) } + + it 'returns 404' do + put api(path, user), params: params + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'PATCH /ai/duo_workflows/workflows/:id' do + let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}" } + + context 'when update workflow status service returns error' do + before do + allow_next_instance_of(::Ai::DuoWorkflows::UpdateWorkflowStatusService) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.error(reason: :bad_request, + message: 'Cannot update workflow status')) + end + end + + it 'returns http error status and error message' do + patch api(path, user), params: { status_event: "finish" } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('Cannot update workflow status') + end + end + + context 'when update workflow status service returns success' do + before do + allow_next_instance_of(::Ai::DuoWorkflows::UpdateWorkflowStatusService) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.success(payload: { workflow: workflow }, + message: 'Workflow status updated')) + end + end + + it 'returns http status ok' do + patch api(path, user), params: { status_event: "finish" } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['workflow']['id']).to eq(workflow.id) + end + end + + context 'when duo_features_enabled settings is turned off' do + before do + workflow.project.project_setting.update!(duo_features_enabled: false) + workflow.project.reload + end + + it 'returns forbidden' do + patch api(path, user), params: { status_event: "finish" } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end +end diff --git a/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb b/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb index 26d02a5c9e3527a34cf76d2588ebb906896d4c1e..a54bf1da1973864fdae33a77feede54946ee622b 100644 --- a/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb +++ b/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb @@ -10,7 +10,7 @@ let_it_be(:user) { create(:user, maintainer_of: project) } let_it_be(:workflow) { create(:duo_workflows_workflow, user: user, project: project) } let_it_be(:duo_workflow_service_url) { 'duo-workflow-service.example.com:50052' } - let_it_be(:oauth_token) { create(:oauth_access_token, user: user, scopes: [:ai_workflows]) } + let_it_be(:ai_workflows_oauth_token) { create(:oauth_access_token, user: user, scopes: [:ai_workflows]) } before do allow(::Gitlab::Llm::StageCheck).to receive(:available?).with(project, :duo_workflow).and_return(true) @@ -30,10 +30,10 @@ end context 'when authenticated with a token that has the ai_workflows scope' do - it 'is successful' do - post api(path, oauth_access_token: oauth_token), params: params + it 'is forbidden' do + post api(path, oauth_access_token: ai_workflows_oauth_token), params: params - expect(response).to have_gitlab_http_status(:created) + expect(response).to have_gitlab_http_status(:forbidden) end end @@ -95,10 +95,10 @@ end context 'when authenticated with a token that has the ai_workflows scope' do - it 'is successful' do - get api(path, oauth_access_token: oauth_token) + it 'is forbidden' do + get api(path, oauth_access_token: ai_workflows_oauth_token) - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:forbidden) end end @@ -124,219 +124,6 @@ end end - describe 'POST /ai/duo_workflows/workflows/:id/checkpoints' do - let(:current_time) { Time.current } - let(:thread_ts) { current_time.to_s } - let(:later_thread_ts) { (current_time + 1.second).to_s } - let(:parent_ts) { (current_time - 1.second).to_s } - let(:checkpoint) { { key: 'value' } } - let(:metadata) { { key: 'value' } } - let(:params) { { thread_ts: thread_ts, checkpoint: checkpoint, parent_ts: parent_ts, metadata: metadata } } - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/checkpoints" } - - it 'allows creating multiple checkpoints for a workflow' do - expect do - post api(path, user), params: params - expect(response).to have_gitlab_http_status(:created) - - post api(path, user), params: params.merge(thread_ts: later_thread_ts, parent_ts: thread_ts) - expect(response).to have_gitlab_http_status(:created) - end.to change { workflow.reload.checkpoints.count }.by(2) - - expect(json_response['id']).to eq(Ai::DuoWorkflows::Checkpoint.last.id) - end - - context 'when authenticated with a token that has the ai_workflows scope' do - it 'is successful' do - post api(path, oauth_access_token: oauth_token), - params: params.merge(thread_ts: later_thread_ts, parent_ts: thread_ts) - - expect(response).to have_gitlab_http_status(:created) - end - end - - it 'fails if the thread_ts is an empty string' do - post api(path, user), params: params.merge(thread_ts: '') - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to include("can't be blank") - end - end - - describe 'GET /ai/duo_workflows/workflows/:id/checkpoints' do - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/checkpoints" } - - it 'returns the checkpoints in descending order of thread_ts' do - checkpoint1 = create(:duo_workflows_checkpoint, workflow: workflow) - checkpoint2 = create(:duo_workflows_checkpoint, workflow: workflow) - workflow.checkpoints << checkpoint1 - workflow.checkpoints << checkpoint2 - - get api(path, user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.pluck('id')).to eq([checkpoint2.id, checkpoint1.id]) - expect(json_response.pluck('thread_ts')).to eq([checkpoint2.thread_ts, checkpoint1.thread_ts]) - expect(json_response.pluck('parent_ts')).to eq([checkpoint2.parent_ts, checkpoint1.parent_ts]) - expect(json_response[0]).to have_key('checkpoint') - expect(json_response[0]).to have_key('metadata') - end - end - - describe 'POST /ai/duo_workflows/workflows/:id/events' do - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events" } - let(:params) do - { - event_type: 'message', - message: 'Hello, World!' - } - end - - context 'when success' do - it 'creates a new event' do - expect do - post api(path, user), params: params - expect(response).to have_gitlab_http_status(:created) - end.to change { workflow.events.count }.by(1) - expect(json_response['id']).to eq(Ai::DuoWorkflows::Event.last.id) - expect(json_response['event_type']).to eq('message') - expect(json_response['message']).to eq('Hello, World!') - expect(json_response['event_status']).to eq('queued') - end - - context 'when authenticated with a token that has the ai_workflows scope' do - it 'is successful' do - expect do - post api(path, oauth_access_token: oauth_token), params: params - expect(response).to have_gitlab_http_status(:created) - end.to change { workflow.events.count }.by(1) - end - end - end - - context 'when required parameters are missing' do - it 'returns bad request when event_type is missing' do - post api(path, user), params: params.except(:event_type) - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to include("event_type is missing") - end - - it 'returns bad request when message is missing' do - post api(path, user), params: params.except(:message) - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to include("message is missing") - end - end - - context 'when invalid event_type is provided' do - it 'returns bad request' do - post api(path, user), params: params.merge(event_type: 'invalid_event_type') - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to include("event_type does not have a valid value") - end - end - - context 'with a workflow belonging to a different user' do - let(:workflow) { create(:duo_workflows_workflow) } - - it 'returns 404' do - post api(path, user), params: params - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - - describe 'GET /ai/duo_workflows/workflows/:id/events' do - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events" } - - it 'returns queued events for the workflow' do - event1 = create(:duo_workflows_event, workflow: workflow, event_status: :queued) - event2 = create(:duo_workflows_event, workflow: workflow, event_status: :queued) - - get api(path, user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to eq(2) - # rubocop:disable Rails/Pluck -- json_response is an array of hashes, we can't use pluck - expect(json_response.map { |e| e['id'] }).to contain_exactly(event1.id, event2.id) - expect(json_response.map { |e| e['event_status'] }).to all(eq('queued')) - # rubocop:enable Rails/Pluck - end - - it 'returns empty array if no queued events' do - create(:duo_workflows_event, workflow: workflow, event_status: :delivered) - - get api(path, user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_empty - end - - context 'with a workflow belonging to a different user' do - let(:workflow) { create(:duo_workflows_workflow) } - - it 'returns 404' do - get api(path, user) - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - - describe 'PUT /ai/duo_workflows/workflows/:id/events/:event_id' do - let(:event) { create(:duo_workflows_event, workflow: workflow, event_status: :queued) } - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events/#{event.id}" } - let(:params) { { event_status: 'delivered' } } - - context 'when success' do - it 'updates the event status' do - put api(path, user), params: params - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['id']).to eq(event.id) - expect(json_response['event_status']).to eq('delivered') - expect(event.reload.event_status).to eq('delivered') - end - - context 'when authenticated with a token that has the ai_workflows scope' do - it 'is successful' do - put api(path, oauth_access_token: oauth_token), params: params - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - context 'when invalid event_status is provided' do - it 'returns bad request' do - put api(path, user), params: { event_status: 'InvalidStatus' } - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to include("event_status does not have a valid value") - end - end - - context 'when the event does not exist' do - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}/events/0" } - - it 'returns 404' do - put api(path, user), params: params - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'with a workflow belonging to a different user' do - let(:workflow) { create(:duo_workflows_workflow) } - - it 'returns 404' do - put api(path, user), params: params - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'with an event belonging to a different workflow' do - let(:other_workflow) { create(:duo_workflows_workflow, user: user, project: project) } - let(:event) { create(:duo_workflows_event, workflow: other_workflow) } - - it 'returns 404' do - put api(path, user), params: params - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - describe 'POST /ai/duo_workflows/direct_access' do let(:path) { '/ai/duo_workflows/direct_access' } @@ -439,48 +226,12 @@ end context 'when authenticated with a token that has the ai_workflows scope' do - it 'succeeds' do - post api(path, oauth_access_token: oauth_token) + it 'is forbidden' do + post api(path, oauth_access_token: ai_workflows_oauth_token) - expect(response).to have_gitlab_http_status(:created) - end - end - end - end - - describe 'PATCH /ai/duo_workflows/workflows/:id' do - let(:path) { "/ai/duo_workflows/workflows/#{workflow.id}" } - - context 'when update workflow status service returns error' do - before do - allow_next_instance_of(::Ai::DuoWorkflows::UpdateWorkflowStatusService) do |service| - allow(service).to receive(:execute).and_return(ServiceResponse.error(reason: :bad_request, - message: 'Cannot update workflow status')) - end - end - - it 'returns http error status and error message' do - patch api(path, user), params: { status_event: "finish" } - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq('Cannot update workflow status') - end - end - - context 'when update workflow status service returns success' do - before do - allow_next_instance_of(::Ai::DuoWorkflows::UpdateWorkflowStatusService) do |service| - allow(service).to receive(:execute).and_return(ServiceResponse.success(payload: { workflow: workflow }, - message: 'Workflow status updated')) + expect(response).to have_gitlab_http_status(:forbidden) end end - - it 'returns http status ok' do - patch api(path, user), params: { status_event: "finish" } - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['workflow']['id']).to eq(workflow.id) - end end end end