diff --git a/app/models/school_project.rb b/app/models/school_project.rb index 52aceb96b..c8171a743 100644 --- a/app/models/school_project.rb +++ b/app/models/school_project.rb @@ -40,7 +40,7 @@ def returned? state_machine.in_state?(:returned) end - delegate :can_transition_to?, :history, to: :state_machine + delegate :can_transition_to?, :history, :in_state?, to: :state_machine private diff --git a/lib/concepts/school_project/set_status.rb b/lib/concepts/school_project/set_status.rb index bccde4d5f..aa677443a 100644 --- a/lib/concepts/school_project/set_status.rb +++ b/lib/concepts/school_project/set_status.rb @@ -4,14 +4,32 @@ class SchoolProject class SetStatus class << self def call(school_project:, status:, user_id:) - response = OperationResponse.new - response[:school_project] = school_project - response[:school_project].transition_status_to!(status, user_id) - response - rescue StandardError => e - Sentry.capture_exception(e) - response[:error] = e.message - response + retry_on(Statesman::TransitionConflictError, attempts: 2, record: school_project) do + response = OperationResponse.new + response[:school_project] = school_project + + return response if school_project.in_state?(status) + + unless school_project.can_transition_to?(status) + message = "Cannot transition from '#{school_project.status}' to '#{status}'" + response[:error] = message + return response + end + + school_project.transition_status_to!(status, user_id) + response + end + end + + private + + def retry_on(exception_class, attempts:, record:) + yield + rescue exception_class + record.reload + attempts -= 1 + retry if attempts.positive? + raise end end end diff --git a/spec/concepts/school_project/set_status_spec.rb b/spec/concepts/school_project/set_status_spec.rb index b8b5c9829..c98f4ed99 100644 --- a/spec/concepts/school_project/set_status_spec.rb +++ b/spec/concepts/school_project/set_status_spec.rb @@ -9,42 +9,54 @@ let(:school_project) { create(:school_project, school:, project:) } describe '.call' do - context 'when status transition is successful' do - it 'returns a successful operation response' do - response = described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(response.success?).to be(true) - end + it 'returns a successful operation response' do + response = described_class.call(school_project:, status: :submitted, user_id: student.id) + expect(response.success?).to be(true) + end - it 'updates the school project status' do - described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(school_project.status).to eq('submitted') - end + it 'updates the school project status' do + described_class.call(school_project:, status: :submitted, user_id: student.id) + expect(school_project.status).to eq('submitted') + end - it 'returns the updated school project in the response' do - response = described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(response[:school_project]).to be_a(SchoolProject) - end + it 'returns the updated school project in the response' do + response = described_class.call(school_project:, status: :submitted, user_id: student.id) + expect(response[:school_project]).to be_a(SchoolProject) end - context 'when status transition fails' do - before do - allow(school_project).to receive(:transition_status_to!).and_raise(StandardError, 'Transition failed') - end + it 'returns an error when transitioning to an invalid status' do + response = described_class.call(school_project:, status: :returned, user_id: student.id) + expect(response.success?).to be(false) + expect(response[:error]).to eq("Cannot transition from '#{school_project.status}' to 'returned'") + end - it 'returns a failed operation response' do - response = described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(response.success?).to be(false) - end + it 'is successful when transitioning to the same status' do + school_project.transition_status_to!(:submitted, student.id) + response = described_class.call(school_project:, status: :submitted, user_id: student.id) + expect(response.success?).to be(true) + expect(school_project.status).to eq('submitted') + end - it 'does not update the school project status' do - described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(school_project.status).to eq('unsubmitted') - end + it 'retries when transition raises a "Statesman::TransitionConflictError" error' do + call_count = 0 + allow(school_project).to receive(:transition_status_to!).and_wrap_original do |original, *args| + call_count += 1 + raise Statesman::TransitionConflictError if call_count == 1 - it 'includes the error message in the response' do - response = described_class.call(school_project:, status: :submitted, user_id: student.id) - expect(response[:error]).to eq('Transition failed') + original.call(*args) end + + response = described_class.call(school_project:, status: :submitted, user_id: student.id) + expect(response.success?).to be(true) + expect(school_project.status).to eq('submitted') + end + + it 'raises the "Statesman::TransitionConflictError" error after 2 attempts' do + allow(school_project).to receive(:transition_status_to!).and_raise(Statesman::TransitionConflictError).twice + + expect do + described_class.call(school_project:, status: :submitted, user_id: student.id) + end.to raise_error(Statesman::TransitionConflictError) end end end diff --git a/spec/features/school_project/complete_spec.rb b/spec/features/school_project/complete_spec.rb index 7e8afa240..242e1a8e5 100644 --- a/spec/features/school_project/complete_spec.rb +++ b/spec/features/school_project/complete_spec.rb @@ -60,21 +60,6 @@ expect(student_project.school_project).to be_complete end end - - context 'when attempting an invalid status transition' do - before do - student_project.school_project.transition_status_to!(:complete, student.id) - post("/api/projects/#{student_project.identifier}/complete", headers:) - end - - it 'completes unauthorized response' do - expect(response).to have_http_status(:unprocessable_content) - end - - it 'completes error message' do - expect(JSON.parse(response.body)['error']).to eq("Cannot transition from 'complete' to 'complete'") - end - end end context 'when user does not own the project and is not the class teacher' do