diff --git a/app/concepts/project/operation/create_remix.rb b/app/concepts/project/operation/create_remix.rb new file mode 100644 index 00000000..298ed876 --- /dev/null +++ b/app/concepts/project/operation/create_remix.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Project + module Operation + class CreateRemix + require 'operation_response' + + def self.call(params) + response = OperationResponse.new + + validate_params(response, params) + return response if response.failure? + + remix_project(response, params) + response + end + + class << self + private + + def validate_params(response, params) + valid = params[:phrase_id].present? && params[:remix][:user_id].present? + response[:error] = 'Invalid parameters' unless valid + end + + def remix_project(response, params) + original_project = Project.find_by!(identifier: params[:phrase_id]) + + response[:project] = original_project.dup.tap do |proj| + proj.user_id = params[:remix][:user_id] + proj.components = original_project.components.map(&:dup) + end + + response[:error] = 'Unable to create project' unless response[:project].save + response + end + end + end + end +end diff --git a/app/controllers/api/projects/phrases_controller.rb b/app/controllers/api/projects/phrases_controller.rb index 6e1e245f..c855d26a 100644 --- a/app/controllers/api/projects/phrases_controller.rb +++ b/app/controllers/api/projects/phrases_controller.rb @@ -21,21 +21,6 @@ def update head :ok end - def remix - old = Project.find_by!(identifier: params[:phrase_id]) - - @project = old.dup - @project.identifier = PhraseIdentifier.generate - - old.components.each do |component| - @project.components << component.dup - end - - @project.save - - render '/api/projects/show', formats: [:json] - end - private def project_params diff --git a/app/controllers/api/projects/remixes_controller.rb b/app/controllers/api/projects/remixes_controller.rb new file mode 100644 index 00000000..38a0c530 --- /dev/null +++ b/app/controllers/api/projects/remixes_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Api + module Projects + class RemixesController < ApiController + def create + result = Project::Operation::CreateRemix.call(remix_params) + + if result.success? + @project = result[:project] + render '/api/projects/show', formats: [:json] + else + render json: { error: result[:error] }, status: :bad_request + end + end + + private + + def remix_params + params.permit(:phrase_id, remix: [:user_id]) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 0dfec3ec..3903d801 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,7 +14,7 @@ namespace :projects do resources :phrases, only: %i[show update] do - post 'remix', to: 'phrases#remix' + resource :remix, only: %i[create] end end end diff --git a/docker-compose.yml b/docker-compose.yml index d0c297e5..6591b4a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: - "3009:3009" depends_on: - db + stdin_open: true + tty: true volumes: pg-data: diff --git a/lib/operation_response.rb b/lib/operation_response.rb new file mode 100644 index 00000000..024e33f9 --- /dev/null +++ b/lib/operation_response.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class OperationResponse < Hash + def success? + return false unless self[:error].nil? + + true + end + + def failure? + !success? + end +end diff --git a/spec/concepts/project/operation/create_remix_spec.rb b/spec/concepts/project/operation/create_remix_spec.rb new file mode 100644 index 00000000..780f9721 --- /dev/null +++ b/spec/concepts/project/operation/create_remix_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Project::Operation::CreateRemix, type: :unit do + subject(:create_remix) { described_class.call(params) } + + let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + let!(:original_project) { create(:project, :with_components) } + + before do + mock_phrase_generation + end + + describe '.call' do + context 'when all params valid' do + let(:params) { { phrase_id: original_project.identifier, remix: { user_id: user_id } } } + + it 'returns success' do + result = create_remix + expect(result.success?).to eq(true) + end + + it 'creates new project' do + expect { create_remix }.to change(Project, :count).by(1) + end + + it 'assigns a new identifer to new project' do + result = create_remix + remixed_project = result[:project] + expect(remixed_project.identifier).not_to eq(original_project.identifier) + end + + it 'assigns user_id to new project' do + remixed_project = create_remix[:project] + expect(remixed_project.user_id).to eq(user_id) + end + + it 'duplicates properties on new project' do + remixed_project = create_remix[:project] + + remixed_attrs = remixed_project.attributes.symbolize_keys.slice(:name, :project_type) + original_attrs = original_project.attributes.symbolize_keys.slice(:name, :project_type) + expect(remixed_attrs).to eq(original_attrs) + end + + it 'duplicates project components' do + remixed_props_array = component_array_props(create_remix[:project].components) + original_props_array = component_array_props(original_project.components) + + expect(remixed_props_array).to match_array(original_props_array) + end + end + + context 'when user_id is not present' do + let(:params) { { phrase_id: original_project.identifier, remix: { user_id: '' } } } + + it 'returns failure' do + result = create_remix + expect(result.failure?).to eq(true) + end + + it 'does not create new project' do + expect { create_remix }.not_to change(Project, :count) + end + end + end + + def component_array_props(components) + components.map do |x| + { + name: x.name, + content: x.content, + extension: x.extension, + index: x.index + } + end + end +end diff --git a/spec/factories/component.rb b/spec/factories/component.rb new file mode 100644 index 00000000..74c43dba --- /dev/null +++ b/spec/factories/component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :component do + name { Faker::Lorem.word } + extension { 'py' } + content { Faker::Lorem.paragraph(sentence_count: 2) } + end +end diff --git a/spec/factories/project.rb b/spec/factories/project.rb index 83361497..7badbdcb 100644 --- a/spec/factories/project.rb +++ b/spec/factories/project.rb @@ -2,9 +2,15 @@ FactoryBot.define do factory :project do - user_id { rand(10**10) } + user_id { SecureRandom.uuid } name { Faker::Book.title } identifier { "#{Faker::Verb.base}-#{Faker::Verb.base}-#{Faker::Verb.base}" } - project_type { %w[python html].sample } + project_type { 'python' } + + trait :with_components do + after(:create) do |object| + object.components = FactoryBot.create_list(:component, 2, project: object) + end + end end end diff --git a/spec/lib/operation_response_spec.rb b/spec/lib/operation_response_spec.rb new file mode 100644 index 00000000..faa9b59c --- /dev/null +++ b/spec/lib/operation_response_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/operation_response' + +RSpec.describe OperationResponse do + describe '#success?' do + context 'when :error not present' do + it 'returns true' do + response = described_class.new + expect(response.success?).to eq(true) + end + end + + context 'when :error has been set' do + it 'returns false' do + response = described_class.new + response[:error] = 'An error' + expect(response.success?).to eq(false) + end + end + end + + describe '#failure?' do + context 'when :error not present' do + it 'returns false' do + response = described_class.new + expect(response.failure?).to eq(false) + end + end + + context 'when :error has been set' do + it 'returns true' do + response = described_class.new + response[:error] = 'An error' + expect(response.failure?).to eq(true) + end + end + end +end diff --git a/spec/models/component_spec.rb b/spec/models/component_spec.rb index 48bab3aa..b041ddad 100644 --- a/spec/models/component_spec.rb +++ b/spec/models/component_spec.rb @@ -3,5 +3,5 @@ require 'rails_helper' RSpec.describe Component, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { is_expected.to belong_to(:project) } end diff --git a/spec/models/word_spec.rb b/spec/models/word_spec.rb deleted file mode 100644 index c9aba94b..00000000 --- a/spec/models/word_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Word, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 77e66855..c2f7fa72 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -13,6 +13,7 @@ abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! + Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } # Requires supporting ruby files with custom matchers and macros, etc, in @@ -69,6 +70,7 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + config.include PhraseIdentifierMock end Shoulda::Matchers.configure do |config| diff --git a/spec/request/projects/remix_spec.rb b/spec/request/projects/remix_spec.rb new file mode 100644 index 00000000..2b7805e1 --- /dev/null +++ b/spec/request/projects/remix_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Remix requests', type: :request do + let!(:original_project) { create(:project) } + let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + + describe 'create' do + before do + mock_phrase_generation + end + + it 'returns expected response' do + post "/api/projects/phrases/#{original_project.identifier}/remix", + params: { remix: { user_id: user_id } } + + expect(response.status).to eq(200) + end + + context 'when request is invalid' do + it 'returns error response' do + post "/api/projects/phrases/#{original_project.identifier}/remix", + params: { remix: { user_id: '' } } + + expect(response.status).to eq(400) + end + end + end +end diff --git a/spec/support/phrase_identifier_mock.rb b/spec/support/phrase_identifier_mock.rb new file mode 100644 index 00000000..18b97b00 --- /dev/null +++ b/spec/support/phrase_identifier_mock.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PhraseIdentifierMock + def mock_phrase_generation(phrase = nil) + # This could cause problems if tests require multiple phrases to be generated + phrase ||= "#{Faker::Verb.base}-#{Faker::Verb.base}-#{Faker::Verb.base}" + + allow(PhraseIdentifier).to receive(:generate).and_return(phrase) + end +end