diff --git a/app/controllers/api/v3/saved_scenario_users_controller.rb b/app/controllers/api/v3/saved_scenario_users_controller.rb new file mode 100644 index 000000000..e90aa63f8 --- /dev/null +++ b/app/controllers/api/v3/saved_scenario_users_controller.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Api + module V3 + class SavedScenarioUsersController < BaseController + before_action do + authorize!(:update, Scenario) + end + + def index + response = my_etm_client.get( + "/api/v1/saved_scenarios/#{params[:saved_scenario_id]}/users" + ) + + render json: response.body + rescue Faraday::ResourceNotFound + render_not_found + rescue Faraday::Error => e + handle_faraday_error(e) + end + + def create + response = my_etm_client.post( + "/api/v1/saved_scenarios/#{params[:saved_scenario_id]}/users", + users_payload + ) + + render json: response.body, status: :created + rescue Faraday::ResourceNotFound + render_not_found + rescue Faraday::Error => e + handle_faraday_error(e) + end + + def update + response = my_etm_client.put( + "/api/v1/saved_scenarios/#{params[:saved_scenario_id]}/users", + users_payload + ) + + render json: response.body + rescue Faraday::ResourceNotFound + render_not_found + rescue Faraday::Error => e + handle_faraday_error(e) + end + + def destroy + response = my_etm_client.delete( + "/api/v1/saved_scenarios/#{params[:saved_scenario_id]}/users" + ) do |req| + req.headers['Content-Type'] = 'application/json' + req.body = users_payload.to_json + end + + render json: response.body + rescue Faraday::ResourceNotFound + render_not_found + rescue Faraday::Error => e + handle_faraday_error(e) + end + + private + + def users_payload + users = params.permit( + saved_scenario_users: %i[id role user_id user_email] + )[:saved_scenario_users] + + { + saved_scenario_users: users&.map(&:to_h) || [] + } + end + + def handle_faraday_error(error) + if error.response + status = error.response[:status] + body = error.response[:body] + + render json: body, status: + else + render json: { errors: ['Failed to connect to MyETM'] }, + status: :service_unavailable + end + end + end + end +end diff --git a/app/controllers/api/v3/scenario_users_controller.rb b/app/controllers/api/v3/scenario_users_controller.rb index fd1f586fa..20c03dfae 100644 --- a/app/controllers/api/v3/scenario_users_controller.rb +++ b/app/controllers/api/v3/scenario_users_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Api module V3 # NOTE: a lot of logic in this controller should not be here. One day this @@ -63,6 +65,12 @@ def destroy json_response end + # DELETE /api/v3/scenarios/:scenario_id/users + def destroy_all + @scenario.scenario_users.destroy_all + head :no_content + end + private def scenario_user_params diff --git a/app/models/scenario.rb b/app/models/scenario.rb index 50a9538d5..d8be2528e 100644 --- a/app/models/scenario.rb +++ b/app/models/scenario.rb @@ -24,7 +24,7 @@ class Scenario < ApplicationRecord belongs_to :parent, class_name: 'Scenario', foreign_key: :preset_scenario_id, optional: true - has_one :preset_scenario, :foreign_key => 'preset_scenario_id', :class_name => 'Scenario' + has_one :preset_scenario, foreign_key: 'preset_scenario_id', class_name: 'Scenario' has_one :scaler, class_name: 'ScenarioScaling', dependent: :delete has_one :scenario_version_tag, dependent: :destroy has_many :heat_network_orders, dependent: :destroy @@ -35,7 +35,6 @@ class Scenario < ApplicationRecord has_many :attachments, dependent: :destroy, class_name: 'ScenarioAttachment' has_many :user_curves, dependent: :destroy - has_many :source_attachments, dependent: :nullify, class_name: 'ScenarioAttachment', @@ -62,11 +61,11 @@ class Scenario < ApplicationRecord validates_associated :scaler, on: :create - scope :by_id, ->(q) { where(id: q)} + scope :by_id, ->(q) { where(id: q) } # Expired ApiScenario will be deleted by rake task :clean_expired_api_scenarios scope :expired, -> { where(['updated_at < ?', Date.today - 14]) } - scope :recent, -> { order("created_at DESC").limit(30) } + scope :recent, -> { order('created_at DESC').limit(30) } scope :recent_first, -> { order('created_at DESC') } scope :with_attachments, -> { includes(attachments: { file_attachment: :blob }) } @@ -88,14 +87,12 @@ class Scenario < ApplicationRecord attr_accessor :input_errors, :ordering, :display_group, :descale before_create do |scenario| - if preset = scenario.preset_scenario + if (preset = scenario.preset_scenario) scenario.copy_scenario_state(preset) end end - def test_scenario=(flag) - @test_scenario = flag - end + attr_writer :test_scenario def test_scenario? @test_scenario == true @@ -107,9 +104,9 @@ def self.default(opts = {}) def self.default_attributes { - :area_code => Etsource::Config.default_dataset_key, - :user_values => {}, - :end_year => 2050 + area_code: Etsource::Config.default_dataset_key, + user_values: {}, + end_year: 2050 }.with_indifferent_access end @@ -119,7 +116,7 @@ def self.new_attributes(settings = {}) out = attributes.merge(settings) # strip invalid attributes valid_attributes = [column_names, 'scenario_id'].flatten - out.delete_if{|key,v| !valid_attributes.include?(key.to_s)} + out.delete_if { |key, _v| valid_attributes.exclude?(key.to_s) } out end @@ -133,14 +130,14 @@ def self.find_for_calculation(id) id_attr = type_for_attribute(:id) id = id_attr.cast(id) - if id.to_i >= 1 << (id_attr.limit * 8 - 1) + if id.to_i >= 1 << ((id_attr.limit * 8) - 1) raise( ActiveRecord::RecordNotFound, "Couldn't find Scenario with an out of range value for 'id'" ) end - where(id: id).with_attachments.first! + where(id:).with_attachments.first! end def self.owned_by?(user) @@ -175,7 +172,6 @@ def scaled? scaler.present? || Area.derived?(area_code) end - # Public: The year on which the analysis for the scenario's area is based. # # Returns an integer. @@ -192,7 +188,7 @@ def years # Creates a scenario from a yml_file. Used by mech turk. def self.create_from_file(yml_file) - settings = YAML::load(File.read(yml_file))['settings'] + settings = YAML.load(File.read(yml_file))['settings'] Scenario.default(settings) end @@ -220,7 +216,7 @@ def self.create_from_json(json_data) def gql(options = {}, &block) unless @gql - if block_given? + if block @gql = Gql::Gql.new(self, &block) else @gql = Gql::Gql.new(self) @@ -248,7 +244,7 @@ def scenario_id=(preset_id) # a identifier for the scenario selector drop down in data. # => "#32341 - nl 2040 (2011-01-11)" def identifier - "##{id} - #{area_code} #{end_year} (#{created_at.strftime("%m-%d %H:%M")})" + "##{id} - #{area_code} #{end_year} (#{created_at.strftime('%m-%d %H:%M')})" end # shortcut to run GQL queries @@ -285,9 +281,7 @@ def outdated? # # Returns a float. def input_value(input) - unless input.respond_to?(:key) - raise ArgumentError, "#{ input.inspect } is not an input" - end + raise ArgumentError, "#{input.inspect} is not an input" unless input.respond_to?(:key) user_values[input.key] || balanced_values[input.key] || @@ -295,8 +289,8 @@ def input_value(input) end def heat_network_order(temperature = :mt) - heat_network_orders.find_by(temperature: temperature) || - HeatNetworkOrder.default(scenario_id: id, temperature: temperature) + heat_network_orders.find_by(temperature:) || + HeatNetworkOrder.default(scenario_id: id, temperature:) end def forecast_storage_order @@ -341,11 +335,11 @@ def clone_should_be_private?(actor) end def owner?(user) - scenario_users.find_by(user: user, role_id: User::ROLES.key(:scenario_owner)) + scenario_users.find_by(user:, role_id: User::ROLES.key(:scenario_owner)) end def collaborator?(user) - scenario_users.find_by(user: user, role_id: User::ROLES.key(:scenario_collaborator)..) + scenario_users.find_by(user:, role_id: User::ROLES.key(:scenario_collaborator)..) end # Convenience method to quickly set the owner for a scenario, e.g. when creating it as @@ -357,7 +351,7 @@ def user=(user) ScenarioUser.create( scenario: self, - user: user, + user:, role_id: User::ROLES.key(:scenario_owner) ) end @@ -369,9 +363,29 @@ def delete_all_users scenario_users.delete_all end - def copy_preset_roles + def copy_preset_roles(preset_scenario_users = nil) return unless parent + if preset_scenario_users.present? + copy_roles_from_user_data(preset_scenario_users) + else + copy_roles_from_parent + end + end + + private + + def copy_roles_from_user_data(users_data) + users_data.each do |user_data| + scenario_users.create( + user_id: user_data['user_id'] || user_data[:user_id], + user_email: user_data['user_email'] || user_data[:user_email], + role_id: user_data['role_id'] || user_data[:role_id] + ) + end + end + + def copy_roles_from_parent parent.scenario_users.each do |preset_user| if (existing_user = scenario_users.find_by(user: preset_user.user)) existing_user.role_id = preset_user.role_id @@ -382,8 +396,6 @@ def copy_preset_roles end end - private - # Validation method for when a user sets their metadata. def validate_metadata_size errors.add(:metadata, 'can not exceed 64Kb') if metadata.to_s.bytesize > 64.kilobytes diff --git a/app/models/scenario_updater.rb b/app/models/scenario_updater.rb index 7f61c6c7f..9f798ab13 100644 --- a/app/models/scenario_updater.rb +++ b/app/models/scenario_updater.rb @@ -59,7 +59,7 @@ def process(scenario_data, provided_values) balanced_values = yield calculate_balanced_values( user_values, provided_values, coupling_state, reset, autobalance, force_balance ) - _balanced = yield validate_balance(user_values, balanced_values, provided_values) + _balanced = yield validate_balance(user_values, balanced_values, provided_values) Success([coupling_state, user_values, balanced_values]) end @@ -75,7 +75,8 @@ def apply(scenario_data, (coupling_state, user_values, balanced_values)) # Post-save def post_save(scenario_data, persisted) set_preset_roles = truthy?(scenario_data[:set_preset_roles]) - _post_saved = yield post_save_operations(set_preset_roles) + preset_scenario_users = scenario_data[:preset_scenario_users] + _post_saved = yield post_save_operations(set_preset_roles, preset_scenario_users) Success(persisted) end @@ -113,25 +114,26 @@ def calculate_user_values(provided_values, coupling_state, reset) ) end - def calculate_balanced_values(user_values, provided_values, coupling_state, reset, autobalance, force_balance) + def calculate_balanced_values(user_values, provided_values, coupling_state, reset, autobalance, + force_balance) service(:CalculateBalancedValues).call( scenario, - user_values: user_values, - provided_values: provided_values, + user_values:, + provided_values:, uncoupled_inputs: coupling_state[:uncoupled_inputs], - reset: reset, - autobalance: autobalance, - force_balance: force_balance + reset:, + autobalance:, + force_balance: ) end def validate_balance(user_values, balanced_values, provided_values) service(:ValidateBalance).call( scenario, - user_values: user_values, - balanced_values: balanced_values, - provided_values: provided_values, - skip_validation: skip_validation + user_values:, + balanced_values:, + provided_values:, + skip_validation: ) end @@ -147,8 +149,9 @@ def persist_scenario(attributes) service(:PersistScenario).call(scenario, attributes, skip_validation) end - def post_save_operations(set_preset_roles) - service(:PostSaveOperations).call(scenario, set_preset_roles, current_user) + def post_save_operations(set_preset_roles, preset_scenario_users) + service(:PostSaveOperations).call(scenario, set_preset_roles, preset_scenario_users, + current_user) end # Helper to instantiate services diff --git a/app/models/scenario_updater/services/post_save_operations.rb b/app/models/scenario_updater/services/post_save_operations.rb index 0cef220d9..ef991b91d 100644 --- a/app/models/scenario_updater/services/post_save_operations.rb +++ b/app/models/scenario_updater/services/post_save_operations.rb @@ -8,8 +8,8 @@ class PostSaveOperations TRUTHY_VALUES = Set.new([true, 'true', '1']).freeze - def call(scenario, set_preset_roles, current_user) - copy_preset_roles_if_requested(scenario, set_preset_roles) + def call(scenario, set_preset_roles, preset_scenario_users, current_user) + copy_preset_roles_if_requested(scenario, set_preset_roles, preset_scenario_users) update_version_tag(scenario, current_user) Success(scenario) @@ -17,9 +17,9 @@ def call(scenario, set_preset_roles, current_user) private - def copy_preset_roles_if_requested(scenario, set_preset_roles) - should_copy = TRUTHY_VALUES.include?(set_preset_roles) - scenario.copy_preset_roles if should_copy + def copy_preset_roles_if_requested(scenario, set_preset_roles, preset_scenario_users) + should_copy = TRUTHY_VALUES.include?(set_preset_roles) || preset_scenario_users.present? + scenario.copy_preset_roles(preset_scenario_users) if should_copy end def update_version_tag(scenario, current_user) diff --git a/app/views/inspect/scenarios/show.html.haml b/app/views/inspect/scenarios/show.html.haml index 96424e5cc..96514c89f 100644 --- a/app/views/inspect/scenarios/show.html.haml +++ b/app/views/inspect/scenarios/show.html.haml @@ -24,8 +24,11 @@ %td - if @scenario.scenario_users.present? - @scenario.scenario_users.each do |scenario_user| - - if scenario_user.user - = link_to("#{scenario_user.user.name} (#{User::ROLES[scenario_user.role_id].to_s.humanize})", user_path(scenario_user.user)) + %div + - if scenario_user.user + = link_to("#{scenario_user.user.name} (#{User::ROLES[scenario_user.role_id].to_s.humanize})", user_path(scenario_user.user)) + - elsif scenario_user.user_email + = "#{scenario_user.user_email} (#{User::ROLES[scenario_user.role_id].to_s.humanize})" - else %span.muted No owner %tr diff --git a/config/routes.rb b/config/routes.rb index 98ed3c39f..9b55a2450 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -93,6 +93,7 @@ post :create put :update delete :destroy + delete :destroy_all end end @@ -112,7 +113,16 @@ end end - resources :saved_scenarios, except: %i[new edit] + resources :saved_scenarios, except: %i[new edit] do + resources :users, only: %i[index create update destroy], controller: 'saved_scenario_users' do + collection do + post :create + put :update + delete :destroy + end + end + end + resources :collections, except: %i[new edit] # Redirecting old transition paths routes to collections diff --git a/spec/controllers/api/v3/saved_scenario_users_controller_spec.rb b/spec/controllers/api/v3/saved_scenario_users_controller_spec.rb new file mode 100644 index 000000000..0b07497ac --- /dev/null +++ b/spec/controllers/api/v3/saved_scenario_users_controller_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Api::V3::SavedScenarioUsersController, type: :controller do + let(:user) { create(:user) } + let(:scenario) { create(:scenario, user:) } + let(:idp_client) { instance_double(Faraday::Connection) } + + before do + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:current_ability).and_return(Ability.new(user)) + allow(controller).to receive(:authorize!).and_return(true) + allow(controller).to receive(:my_etm_client).and_return(idp_client) + + request.headers.merge!(access_token_header(user, :write)) + end + + describe 'GET #index' do + let(:users_data) do + [ + { 'user_id' => user.id, 'user_email' => nil, 'role' => 'scenario_owner' } + ] + end + + context 'with a valid saved scenario ID' do + before do + response_double = instance_double(Faraday::Response, body: users_data) + allow(idp_client).to receive(:get) + .with('/api/v1/saved_scenarios/1/users') + .and_return(response_double) + + get :index, params: { saved_scenario_id: 1 } + end + + it 'responds successfully' do + expect(response).to have_http_status(:ok) + end + + it 'returns the list of users' do + parsed = JSON.parse(response.body) + expect(parsed).to eq(users_data) + end + end + + context 'with an invalid saved scenario ID' do + before do + allow(idp_client).to receive(:get).and_raise(Faraday::ResourceNotFound) + get :index, params: { saved_scenario_id: 999 } + end + + it 'responds with not found' do + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST #create' do + let(:params) do + { + saved_scenario_id: 1, + saved_scenario_users: [ + { user_email: 'viewer@test.com', role: 'scenario_viewer' }, + { user_email: 'collaborator@test.com', role: 'scenario_collaborator' } + ] + } + end + + let(:created_users) do + [ + { 'user_id' => nil, 'user_email' => 'viewer@test.com', 'role' => 'scenario_viewer' }, + { 'user_id' => nil, 'user_email' => 'collaborator@test.com', + 'role' => 'scenario_collaborator' } + ] + end + + context 'when successful' do + before do + response_double = instance_double(Faraday::Response, body: created_users) + allow(idp_client).to receive(:post) + .with('/api/v1/saved_scenarios/1/users', hash_including(saved_scenario_users: kind_of(Array))) + .and_return(response_double) + + post :create, params: + end + + it 'responds with created status' do + expect(response).to have_http_status(:created) + end + + it 'returns the created users' do + parsed = JSON.parse(response.body) + expect(parsed).to eq(created_users) + end + end + + context 'when MyEtm returns validation errors' do + let(:error_response) do + { + body: { 'errors' => { 'viewer@test.com' => ['user_email'] } }, + status: 422 + } + end + + before do + error = Faraday::UnprocessableEntityError.new(error_response) + allow(error).to receive(:response).and_return(error_response) + allow(idp_client).to receive(:post).and_raise(error) + + post :create, params: + end + + it 'responds with unprocessable entity status' do + expect(response).to have_http_status(:unprocessable_content) + end + + it 'returns the error message' do + parsed = JSON.parse(response.body) + expect(parsed['errors']).to be_present + end + end + end + + describe 'PUT #update' do + let(:params) do + { + saved_scenario_id: 1, + saved_scenario_users: [ + { user_id: user.id, role: 'scenario_viewer' } + ] + } + end + + let(:updated_users) do + [ + { 'user_id' => user.id, 'user_email' => nil, 'role' => 'scenario_viewer' } + ] + end + + context 'when successful' do + before do + response_double = instance_double(Faraday::Response, body: updated_users) + allow(idp_client).to receive(:put) + .with('/api/v1/saved_scenarios/1/users', hash_including(saved_scenario_users: kind_of(Array))) + .and_return(response_double) + + put :update, params: + end + + it 'responds successfully' do + expect(response).to have_http_status(:ok) + end + + it 'returns the updated users' do + parsed = JSON.parse(response.body) + expect(parsed).to eq(updated_users) + end + end + + context 'with a non-existing user' do + let(:error_response) do + { + body: { 'errors' => { '999' => ['Scenario user not found'] } }, + status: 422 + } + end + + before do + error = Faraday::UnprocessableEntityError.new(error_response) + allow(error).to receive(:response).and_return(error_response) + allow(idp_client).to receive(:put).and_raise(error) + + put :update, params: { saved_scenario_id: 1, saved_scenario_users: [{ user_id: 999 }] } + end + + it 'responds with unprocessable entity status' do + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe 'DELETE #destroy' do + let(:params) do + { + saved_scenario_id: 1, + saved_scenario_users: [ + { user_id: 123 } + ] + } + end + + let(:deleted_users) do + [ + { 'user_id' => 123, 'user_email' => nil, 'role' => 'scenario_viewer' } + ] + end + + context 'when successful' do + before do + request_stub = instance_double(Faraday::Request, headers: {}, 'body=': nil) + response_double = instance_double(Faraday::Response, body: deleted_users) + + allow(idp_client).to receive(:delete) + .with('/api/v1/saved_scenarios/1/users') + .and_yield(request_stub) + .and_return(response_double) + + delete :destroy, params: + end + + it 'responds successfully' do + expect(response).to have_http_status(:ok) + end + + it 'returns the deleted users' do + parsed = JSON.parse(response.body) + expect(parsed).to eq(deleted_users) + end + end + + context 'when trying to delete the last owner' do + let(:error_response) do + { + body: { 'errors' => { '' => ['base'] } }, + status: 422 + } + end + + before do + error = Faraday::UnprocessableEntityError.new(error_response) + allow(error).to receive(:response).and_return(error_response) + allow(idp_client).to receive(:delete).and_raise(error) + + delete :destroy, params: + end + + it 'responds with unprocessable entity status' do + expect(response).to have_http_status(:unprocessable_content) + end + end + end +end diff --git a/spec/models/scenario_spec.rb b/spec/models/scenario_spec.rb index 19ea8e75d..8c8dc7b49 100644 --- a/spec/models/scenario_spec.rb +++ b/spec/models/scenario_spec.rb @@ -1,13 +1,16 @@ +# frozen_string_literal: true + require 'spec_helper' describe Scenario do - before { @scenario = Scenario.new } subject { @scenario } + before { @scenario = described_class.new } + describe '.find_for_calculation' do context 'when the scenario exists' do it 'returns the scenario' do - scenario = FactoryBot.create(:scenario) + scenario = create(:scenario) expect(described_class.find_for_calculation(scenario.id)).to eq(scenario) end end @@ -27,21 +30,24 @@ end end - describe "#default" do - subject { Scenario.default } + describe '#default' do + subject { described_class.default } describe '#area_code' do subject { super().area_code } - it { is_expected.to eq('nl')} + + it { is_expected.to eq('nl') } end describe '#user_values' do subject { super().user_values } + it { is_expected.to eq({}) } end describe '#end_year' do subject { super().end_year } + it { is_expected.to eq(2050) } context 'when it not provided' do @@ -58,7 +64,7 @@ scenario = described_class.default(end_year: 2019.5) scenario.valid? - expect(scenario.errors[:end_year]).to include("must be an integer") + expect(scenario.errors[:end_year]).to include('must be an integer') end end @@ -95,7 +101,7 @@ describe 'when the dataset does not exist' do let(:scenario) do - described_class.default.tap { |s| s.area_code = 'invalid'} + described_class.default.tap { |s| s.area_code = 'invalid' } end it 'returns 2019' do @@ -104,9 +110,10 @@ end end - describe "#years" do + describe '#years' do describe '#years' do subject { super().years } + it { is_expected.to eq(39) } end end @@ -129,41 +136,41 @@ let(:since) { 1.month.ago } let!(:scenario) do - FactoryBot.create( + create( :scenario, user_values: { a: 1 }, - source: 'ETM', + source: 'ETM' ) end context 'with a writeable scenarios' do it 'includes recent scenarios' do - is_expected.to include(scenario) + expect(subject).to include(scenario) end it 'includes scenarios with no source' do scenario.update_attribute(:source, nil) - is_expected.to include(scenario) + expect(subject).to include(scenario) end it 'omits recent scenarios where source="Mechanical Turk"' do scenario.update_attribute(:source, 'Mechanical Turk') - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end it 'omits scenarios updated prior to the "since" date' do scenario.update_attribute(:updated_at, since - 1.hour) - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end it 'omits scenarios with NULL user_values' do scenario.update_attribute(:user_values, nil) - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end it 'omits scenarios with empty user_values' do scenario.update_attribute(:user_values, {}) - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end end @@ -171,71 +178,76 @@ before { scenario.update(keep_compatible: true) } it 'includes recent scenarios' do - is_expected.to include(scenario) + expect(subject).to include(scenario) end it 'includes scenarios with no source' do scenario.update_attribute(:source, nil) - is_expected.to include(scenario) + expect(subject).to include(scenario) end it 'omits recent scenarios where source="Mechanical Turk"' do scenario.update_attribute(:source, 'Mechanical Turk') - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end it 'incudes scenarios updated prior to the "since" date' do scenario.update_attribute(:updated_at, since - 1.hour) - is_expected.to include(scenario) + expect(subject).to include(scenario) end it 'omits scenarios with NULL user_values' do scenario.update_attribute(:user_values, nil) - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end it 'omits scenarios with empty user_values' do scenario.update_attribute(:user_values, {}) - is_expected.not_to include(scenario) + expect(subject).not_to include(scenario) end end end - describe "user supplied parameters" do - it "should not allow a bad area code" do - s = Scenario.new(:area_code => '{}') - expect(s).to_not be_valid + describe 'user supplied parameters' do + it 'does not allow a bad area code' do + s = described_class.new(area_code: '{}') + expect(s).not_to(be_valid) expect(s.errors[:area_code]) end - it "should not allow a bad year format" do - s = Scenario.new(:end_year => 'abc') - expect(s).to_not be_valid + it 'does not allow a bad year format' do + s = described_class.new(end_year: 'abc') + expect(s).not_to(be_valid) expect(s.errors[:end_year]) end end - describe "#user_values" do - context ":user_values = YAMLized object with string keys and string values (when coming from db)" do - before { @scenario = Scenario.new(user_values: { foo: :bar }) } - it "should unyaml, overwrite and return user_values" do + describe '#user_values' do + context ':user_values = YAMLized object with string keys and string values (when coming from db)' do + before { @scenario = described_class.new(user_values: { foo: :bar }) } + + it 'unyaml,s overwrite and return user_values' do expect(@scenario.user_values).to eq({ 'foo' => 'bar' }) expect(@scenario.user_values['foo']).to eq('bar') end end - context ":user_values = nil" do - before { @scenario = Scenario.new(user_values: nil) } + context ':user_values = nil' do + before { @scenario = described_class.new(user_values: nil) } + describe '#user_values' do subject { super().user_values } + it { is_expected.to eq({}) } end end - context ":user_values = obj" do - before { @scenario = Scenario.new(user_values: {}) } + context ':user_values = obj' do + before { @scenario = described_class.new(user_values: {}) } + describe '#user_values' do subject { super().user_values } + it { is_expected.to eq({}) } end end @@ -245,50 +257,53 @@ let(:input) { Input.new(key: 'my-input', start_value: 99.0) } before { allow(Input).to receive(:all).and_return([input]) } + before { Rails.cache.clear } context 'with a user value present' do let(:scenario) do - FactoryBot.create(:scenario, { + create(:scenario, { user_values: { 'my-input' => 20.0 }, balanced_values: { 'my-input' => 50.0 } }) end it 'returns the user value' do - expect(scenario.input_value(input)).to eql(20.0) + expect(scenario.input_value(input)).to be(20.0) end end context 'with a balanced value present' do let(:scenario) do - FactoryBot.create(:scenario, balanced_values: { 'my-input' => 50.0 }) + create(:scenario, balanced_values: { 'my-input' => 50.0 }) end it 'returns the balanced value' do - expect(scenario.input_value(input)).to eql(50.0) + expect(scenario.input_value(input)).to be(50.0) end end context 'with no user or balanced value' do - let(:scenario) { FactoryBot.create(:scenario) } + let(:scenario) { create(:scenario) } it "returns the input's default value" do - expect(scenario.input_value(input)).to eql(99.0) + expect(scenario.input_value(input)).to be(99.0) end end context 'given nil' do it 'raises an error' do - expect { FactoryBot.create(:scenario).input_value(nil) }. - to raise_error(/nil is not an input/) + expect { create(:scenario).input_value(nil) } + .to raise_error(/nil is not an input/) end end end - describe "#used_groups_add_up?" do + describe '#used_groups_add_up?' do + subject { @scenario } + before do - @scenario = Scenario.default + @scenario = described_class.default allow(Input).to receive(:inputs_grouped).and_return({ 'share_group' => [ double('Input', id: 1, share_group: 'share_group'), @@ -301,47 +316,52 @@ ] }) end - subject { @scenario } - describe "#used_groups" do - context "no user_values" do + describe '#used_groups' do + context 'no user_values' do describe '#used_groups' do subject { super().used_groups } + it { is_expected.to be_empty } end end - context "with 1 user_values" do - before { @scenario.user_values = {1 => 2}} + + context 'with 1 user_values' do + before { @scenario.user_values = { 1 => 2 } } describe '#used_groups' do subject { super().used_groups } + it { is_expected.not_to be_empty } end end end - describe "#used_groups_add_up?" do - context "no user_values" do + describe '#used_groups_add_up?' do + context 'no user_values' do describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_truthy } end end - context "user_values but without groups" do - before { @scenario.user_values = {10 => 2}} + context 'user_values but without groups' do + before { @scenario.user_values = { 10 => 2 } } describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_truthy } end end context "user_values that don't add up to 100" do - before { @scenario.user_values = {1 => 50}} + before { @scenario.user_values = { 1 => 50 } } describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_falsey } end @@ -354,28 +374,30 @@ end end - context "user_values that add up to 100" do - before { @scenario.user_values = {1 => 50, 2 => 30, 3 => 20}} + context 'user_values that add up to 100' do + before { @scenario.user_values = { 1 => 50, 2 => 30, 3 => 20 } } describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_truthy } end end - context "with balanced values which add up to 100" do + context 'with balanced values which add up to 100' do before do - @scenario.user_values = { 1 => 50} + @scenario.user_values = { 1 => 50 } @scenario.balanced_values = { 2 => 20, 3 => 30 } end describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_truthy } end end - context "with balanced values which do not add up to 100" do + context 'with balanced values which do not add up to 100' do before do @scenario.user_values = { 1 => 40 } @scenario.balanced_values = { 2 => 20, 3 => 30 } @@ -383,6 +405,7 @@ describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_falsey } end @@ -395,7 +418,7 @@ end end - context "with only balanced values which add up to 100" do + context 'with only balanced values which add up to 100' do before do @scenario.user_values = {} @scenario.balanced_values = { 1 => 50, 2 => 20, 3 => 30 } @@ -403,18 +426,18 @@ describe '#used_groups_add_up?' do subject { super().used_groups_add_up? } + it { is_expected.to be_truthy } end end - end end - describe "#coupled?" do + describe '#coupled?' do subject { @scenario.coupled? } before do - @scenario = Scenario.default + @scenario = described_class.default allow(Input).to receive(:coupling_inputs_keys).and_return( ['coupled_slider_1'] ) @@ -443,38 +466,38 @@ describe 'with a preset scenario' do let(:preset) do - FactoryBot.create(:scenario, { - id: 99999, # Avoid a collision with a preset ID + create(:scenario, { + id: 99_999, # Avoid a collision with a preset ID user_values: { 'grouped_input_one' => 1 }, balanced_values: { 'grouped_input_two' => 2 } }) end let(:scenario) do - Scenario.new(scenario_id: preset.id) + described_class.new(scenario_id: preset.id) end - it 'should retrieve the parent' do + it 'retrieves the parent' do expect(scenario.parent).to eq(preset) end - it 'should copy the user values' do + it 'copies the user values' do expect(scenario.user_values).to eql(preset.user_values) end - it 'should copy the balanced values' do + it 'copies the balanced values' do expect(scenario.balanced_values).to eql(preset.balanced_values) end - it 'should copy the scaler attributes' do + it 'copies the scaler attributes' do ScenarioScaling.create!( scenario: preset, area_attribute: 'present_number_of_residences', value: 1000 ) - expect(scenario.scaler).to_not be_nil - expect(scenario.scaler.id).to_not eq(preset.scaler.id) + expect(scenario.scaler).not_to(be_nil) + expect(scenario.scaler.id).not_to(eq(preset.scaler.id)) expect(scenario.scaler.area_attribute).to eq('present_number_of_residences') expect(scenario.scaler.value).to eq(1000) @@ -482,7 +505,7 @@ end context 'with no preset heat network order' do - it 'should create no heat network order' do + it 'creates no heat network order' do expect(scenario[:heat_network_order]).to be_nil end end @@ -490,16 +513,16 @@ context 'with a custom interconnector 1 electricity price curve' do let!(:preset) { create(:scenario) } let!(:scenario) { create(:scenario) } - let(:curve_data) { File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) } + let(:curve_data) do + File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) + end let(:preset_curve) do create(:user_curve, scenario: preset, key: 'interconnector_1_price_curve', - curve: Merit::Curve.new(curve_data) - ) + curve: Merit::Curve.new(curve_data)) end - let(:scenario_curve) { scenario.user_curves.find_by(key: 'interconnector_1_price_curve') } before do @@ -527,13 +550,14 @@ context 'with a custom interconnector 2 electricity price curve' do let!(:preset) { create(:scenario) } let!(:scenario) { create(:scenario) } - let(:curve_data) { File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) } + let(:curve_data) do + File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) + end let(:preset_curve) do create(:user_curve, scenario: preset, key: 'interconnector_2_price_curve', - curve: Merit::Curve.new(curve_data) - ) + curve: Merit::Curve.new(curve_data)) end let(:scenario_curve) { scenario.user_curves.find_by(key: 'interconnector_2_price_curve') } @@ -570,8 +594,8 @@ end it 'copies the flexibility order attributes' do - expect(scenario.heat_network_order).to_not be_nil - expect(scenario.heat_network_order.id).to_not eq(preset.heat_network_order.id) + expect(scenario.heat_network_order).not_to(be_nil) + expect(scenario.heat_network_order.id).not_to(eq(preset.heat_network_order.id)) scenario.save! expect(scenario.heat_network_order.order).to eq(techs) expect(scenario.heat_network_order.scenario).to eq(scenario) # Not `preset`. @@ -581,8 +605,8 @@ describe 'cloning a scaled scenario to an unscaled scenario' do let(:preset) do - FactoryBot.create(:scenario, { - id: 99999, # Avoid a collision with a preset ID + create(:scenario, { + id: 99_999, # Avoid a collision with a preset ID user_values: { 'grouped_input_one' => 2 }, balanced_values: { 'grouped_input_two' => 8 }, @@ -594,7 +618,7 @@ end let(:scenario) do - scenario = Scenario.new + scenario = described_class.new scenario.descale = true scenario.scenario_id = preset.id scenario.save! @@ -605,7 +629,7 @@ let(:multiplier) { Atlas::Dataset.find(:nl).present_number_of_residences / 1000 } it 'creates the scenario' do - expect(scenario).to_not be_new_record + expect(scenario).not_to(be_new_record) end it 'sets no scaler' do @@ -613,13 +637,13 @@ end it 'adjusts the input values to fit the full-size region' do - expect(scenario.user_values). - to eq({'grouped_input_one' => 2 * multiplier}) + expect(scenario.user_values) + .to eq({ 'grouped_input_one' => multiplier * 2 }) end it 'adjusts the balanced values to fit the full-size region' do - expect(scenario.balanced_values). - to eq({'grouped_input_two' => 8 * multiplier}) + expect(scenario.balanced_values) + .to eq({ 'grouped_input_two' => multiplier * 8 }) end context 'with a non-existent input' do @@ -629,34 +653,33 @@ end it 'does not raise an error' do - expect { scenario }.to_not raise_error + expect { scenario }.not_to(raise_error) end it 'skips the input' do - expect(scenario.user_values.keys).to_not include('invalid') + expect(scenario.user_values.keys).not_to(include('invalid')) end end end describe 'dup' do let(:scenario) do - Scenario.create!( + described_class.create!( end_year: 2030, area_code: 'nl', user_values: { 1 => 2, 3 => 4 }, balanced_values: { 5 => 6 } ) end + let(:dup) { scenario.dup } - before(:each) do + before do scenario.inputs scenario.gql end - let(:dup) { scenario.dup } - it 'clones the end year' do - expect(dup.end_year).to eql(2030) + expect(dup.end_year).to be(2030) end it 'clones the area' do @@ -689,7 +712,9 @@ end context 'with two scenarios using the same curve values' do - let(:curve_data) { File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) } + let(:curve_data) do + File.read(Rails.root.join('spec/fixtures/files/price_curve.csv')).lines.map(&:to_f) + end let!(:scenario_one) { create(:scenario) } let!(:scenario_two) { create(:scenario) } @@ -698,16 +723,14 @@ create(:user_curve, scenario: scenario_one, key: 'interconnector_1_price_curve', - curve: Merit::Curve.new(curve_data) - ) + curve: Merit::Curve.new(curve_data)) end let!(:curve_two) do create(:user_curve, scenario: scenario_two, key: 'interconnector_1_price_curve', - curve: Merit::Curve.new(curve_data) - ) + curve: Merit::Curve.new(curve_data)) end it 'both scenarios have a user curve' do @@ -724,9 +747,9 @@ end it 'deleting one curve does not delete the other' do - expect { + expect do curve_one.destroy - }.to change(UserCurve, :count).by(-1) + end.to change(UserCurve, :count).by(-1) expect(UserCurve.exists?(curve_two.id)).to be(true) end @@ -737,7 +760,7 @@ context 'with no metadata' do it 'responds with nil when requesting a key' do - expect(scenario.metadata["ctm_scenario_id"]).to be_nil + expect(scenario.metadata['ctm_scenario_id']).to be_nil end end @@ -745,19 +768,19 @@ before { scenario.metadata = {} } it 'responds with nil when requesting a key' do - expect(scenario.metadata["ctm_scenario_id"]).to be_nil + expect(scenario.metadata['ctm_scenario_id']).to be_nil end end context 'with metadata present' do - before { scenario.metadata = { "ctm_scenario_id" => 12_345, "kittens" => "mew" } } + before { scenario.metadata = { 'ctm_scenario_id' => 12_345, 'kittens' => 'mew' } } it 'stores numeric data' do - expect(scenario.metadata["ctm_scenario_id"]).to eq(12_345) + expect(scenario.metadata['ctm_scenario_id']).to eq(12_345) end it 'stores string data' do - expect(scenario.metadata["kittens"]).to eq("mew") + expect(scenario.metadata['kittens']).to eq('mew') end it 'does not have metadata accessible by accessor' do @@ -767,8 +790,8 @@ context 'when setting metadata' do it 'permits a hash' do - scenario.metadata = { "a" => 1 } - expect(scenario.metadata).to eq({ "a" => 1 }) + scenario.metadata = { 'a' => 1 } + expect(scenario.metadata).to eq({ 'a' => 1 }) end it 'permits a hash' do @@ -782,13 +805,14 @@ end it 'denies objects larger than 64Kb' do - scenario.metadata = (0..15_000).to_h { |i| [i.to_s, i] } + scenario.metadata = (0..15_000).index_by(&:to_s) expect(scenario).not_to be_valid end end context 'when creating a clone of a scenario' do - before { scenario.metadata = { "ctm_scenario_id" => 12_345, "kittens" => "mew" } } + before { scenario.metadata = { 'ctm_scenario_id' => 12_345, 'kittens' => 'mew' } } + let(:scenario_clone) { described_class.new(scenario_id: scenario.id) } it 'keeps the original metadata' do @@ -816,8 +840,8 @@ let(:preset_collaborator) { create(:user) } let(:preset) do - FactoryBot.create(:scenario, { - id: 99999, # Avoid a collision with a preset ID + create(:scenario, { + id: 99_999, # Avoid a collision with a preset ID user_values: { 'grouped_input_one' => 1 }, balanced_values: { 'grouped_input_two' => 2 }, user: preset_owner @@ -838,11 +862,44 @@ it 'sets the owner of the preset' do subject - expect(scenario.owner?(preset_owner)).to be_truthy + expect(scenario).to be_owner(preset_owner) end it 'sets the collaborator of the preset' do expect { subject }.to(change { scenario.owner?(preset_collaborator) }) end + + context 'with preset_scenario_users data' do + subject do + scenario.copy_preset_roles(users_data) + scenario.reload + end + + let(:user1) { create(:user) } + let(:user2) { create(:user) } + + let(:users_data) do + [ + { user_id: user1.id, user_email: nil, role_id: 3 }, + { user_id: nil, user_email: 'pending@example.com', role_id: 2 } + ] + end + + it 'creates scenario users from the provided data' do + expect { subject }.to change(scenario.scenario_users, :count).by(2) + end + + it 'sets the owner from user_id' do + subject + expect(scenario).to be_owner(user1) + end + + it 'creates pending user with email' do + subject + pending_user = scenario.scenario_users.find_by(user_email: 'pending@example.com') + expect(pending_user).to be_present + expect(pending_user.role_id).to eq(2) + end + end end end diff --git a/spec/models/scenario_updater/services/post_save_operations_spec.rb b/spec/models/scenario_updater/services/post_save_operations_spec.rb index f9d2453c6..01fea8e38 100644 --- a/spec/models/scenario_updater/services/post_save_operations_spec.rb +++ b/spec/models/scenario_updater/services/post_save_operations_spec.rb @@ -3,18 +3,24 @@ require 'spec_helper' RSpec.describe ScenarioUpdater::Services::PostSaveOperations do - let(:scenario) { FactoryBot.create(:scenario) } + let(:scenario) { create(:scenario) } let(:service) { described_class.new } it 'copies preset roles if requested' do - expect(scenario).to receive(:copy_preset_roles) - service.call(scenario, true, 'user') + expect(scenario).to receive(:copy_preset_roles).with(nil) + service.call(scenario, true, nil, 'user') + end + + it 'copies preset roles with user data' do + users_data = [{ user_id: 1, user_email: nil, role_id: 3 }] + expect(scenario).to receive(:copy_preset_roles).with(users_data) + service.call(scenario, false, users_data, 'user') end it 'updates version tag' do version_tag = double('VersionTag') allow(scenario).to receive(:scenario_version_tag).and_return(version_tag) expect(version_tag).to receive(:update).with(user: 'user') - service.call(scenario, false, 'user') + service.call(scenario, false, nil, 'user') end end