I work on a booking application, where each Home can have several Phone.
I would like to limit the number of Phone by Home to 3, and display a nice error in the create phone form.
How can I achieve that, in a rails way ?
code
class Phone < ApplicationRecord
belongs_to :user
validates :number, phone: true
# validates_associated :homes_phones
has_many :homes_phones, dependent: :destroy
has_many :homes, through: :homes_phones
end
class User < ApplicationRecord
has_many :phones, dependent: :destroy
end
class HomesPhone < ApplicationRecord
belongs_to :home
belongs_to :phone
validate :check_phones_limit
def check_phones_limit
errors.add(:base, "too_many_phones") if home.phones.size >= 3
end
end
specs
it 'should limit phones to 3' do
user = create(:user)
home = create(:home, :active, manager: user)
expect(home.phones.create(user: user, number: "+33611223344")).to be_valid
expect(home.phones.create(user: user, number: "+33611223345")).to be_valid
expect(home.phones.create(user: user, number: "+33611223346")).to be_valid
# unexpectedly raises a ActiveRecord::RecordInvalid
expect(home.phones.create(user: user, number: "+33611223347")).to be_invalid
end
Side notes
My understanding of the flow is:
a transaction opens
phone attributes are validated (and valid)
phone is created, a primary key is available
homes_phone is saved! and an error is raised because the validation fails
the all transaction is rolled back, and the error bubble up
I have tried:
has_many before_add in Home which also raise an error;
validating these rules in Phone does not make sense to me, as this rule is a Home concern
You can just validate it in the controller, count the phones and render a flash error, before you actually try and save the records.
Doing this in callbacks is difficult and not foolproof.
I've added some quick tests to cover different ways phones can be created:
# spec/models/homes_phone_spec.rb
require "rails_helper"
RSpec.describe HomesPhone, type: :model do
it "saves 3 new phones" do
home = Home.create(phones: 3.times.map { Phone.new })
expect(home.phones.count).to eq 3
end
it "doesn't save 4 new phones" do
home = Home.create(phones: 4.times.map { Phone.new })
expect(home.phones.count).to eq 0
expect(home.phones.size).to eq 4
end
it "can create up to 3 three phones through association" do
home = Home.create!
expect do
5.times { home.phones.create }
end.to change(home.phones, :count).by(3)
end
it "doesn't add 4th phone to existing record" do
Home.create(phones: 3.times.map { Phone.new })
home = Home.last
# NOTE: every time you call `valid?` or `validate`, it runs validations
# again and all previous errors are cleared.
# expect(home.phones.create).to be_invalid
expect(home.phones.create).to be_new_record
# or
# expect(home.phones.create).not_to be_persisted
# expect(home.phones.create.errors.any?).to be true
end
it "adds phone limit validation error to Phone" do
home = Home.create(phones: 3.times.map { Phone.new })
phone = home.phones.create
expect(phone.errors[:base]).to eq ["too many phones"]
end
end
# app/models/phone.rb
class Phone < ApplicationRecord
has_many :homes_phones, dependent: :destroy
has_many :homes, through: :homes_phones
end
# app/models/home.rb
class Home < ApplicationRecord
has_many :homes_phones, dependent: :destroy
has_many :phones, through: :homes_phones
end
# app/models/homes_phone.rb
class HomesPhone < ApplicationRecord
belongs_to :home
# NOTE: setting `inverse_of` option will stop raising errors and
# just return `phone` with errors that we'll add below
belongs_to :phone, inverse_of: :homes_phones
# because of the way through association is saved with `save!` call
# it raises validation errors. `inverse_of` allows the through association
# to be properly built and it skips `save!` call:
# https://github.com/rails/rails/blob/v7.0.4.2/activerecord/lib/active_record/associations/has_many_through_association.rb#L79-L80
validate do
# NOTE: if you use `homes_phones` association, the `size` method returns
# current count, instead of a lagging `phones.size` count.
if home.homes_phones.size > 3
errors.add(:base, "too many phones")
phone.errors.merge!(errors)
end
end
end
$ rspec spec/models/homes_phone_spec.rb
HomesPhone
saves 3 new phones
doesn't save 4 new phones
can create up to 3 three phones through association
doesn't add 4th phone to existing record
adds phone limit validation to Phone
Finished in 0.10967 seconds (files took 2.35 seconds to load)
5 examples, 0 failures
But it doesn't cover everything, like this:
it "can append up to 3 three phones" do
home = Home.create!
expect do
5.times { home.phones << Phone.new }
end.to change(home.phones, :count).by(3)
end
$ rspec spec/models/homes_phone_spec.rb:38
Run options: include {:locations=>{"./spec/models/homes_phone_spec.rb"=>[38]}}
HomesPhone
can append up to 3 three phones (FAILED - 1)
and I thought I fixed everything. You can try this instead:
after_validation do
if home.homes_phones.size > 3
errors.add(:base, "too many phones")
phone.errors.merge! errors
raise ActiveRecord::Rollback
end
end
$ rspec spec/models/homes_phone_spec.rb -f p
......
Finished in 0.12411 seconds (files took 2.25 seconds to load)
6 examples, 0 failures
This is the rails way of doing this, in your Home class
validates :phones, length: { maximum: 3, message: "too many phones" }
You could think validation is something like this: it target to the Model we want to create directly.
In your case, you want to validate when create phones, so you have to put the validation rule into the Phone model.
This way the validation will take effect, and you could get the error message correctly.
Related
I have a LabCollection:
class LabCollection < ApplicationRecord
# Relationships
belongs_to :lab_container, polymorphic: true, optional: true
has_many :lab_collection_labs
has_many :labs, -> { published }, through: :lab_collection_labs
has_many :lab_collection_inclusions, dependent: :destroy
end
It has many LabCollectionLabs:
class LabCollectionLab < ApplicationRecord
acts_as_list scope: :lab_collection_id, add_new_at: :bottom
belongs_to :lab_collection
belongs_to :lab
end
Which has a lab ID and belongs to a lab.
I have a spec which tests how new associations are created, and it's failling at the following point:
context 'when the lab container has labs already present' do
it 'removes the present labs and adds the new ones' do
subject = RawLabAdder
expect(populated_lab_collection.labs).to eq labs
subject.new(populated_lab_collection, [lab4.id, lab5.id]).perform
expect(populated_lab_collection.labs).not_to eq labs
binding.pry
expect(populated_lab_collection.labs).to eq [lab4, lab5]
end
end
If you need to see the internal working of the code let me know, however the issue seems to be with RSpec and refreshing associations. When I hit the binding.pry point and call populated_lab_collection.lab_collection_labs
populated_lab_collection.lab_collection_labs
=>
[lab_collection_lab_4, lab_collection_lab_5]
However when I call .labs instead:
populated_lab_collection.labs
=>
[]
Inspecting the lab_collection_labs, I can see that they each have a lab_id and that a lab exists for those ID's. I believe my problem is that I'm not refreshing the records correctly, however I've tried:
# populated_lab_collection.reload
# populated_lab_collection.lab_collection_labs.reload
# populated_lab_collection.lab_collection_labs.each do |x|
# x.lab.reload
# end
# populated_lab_collection.labs.reload
Any advice on how I can get RSpec to correctly read in a records nested associations is greatly appreciated. As I say when I inspect the record, it has 2 lab_inclusion_labs, which each have a lab, however the parent record apparently has no labs.
EDIT: RawLabAdder class:
module LabCollections
class RawLabAdder
def initialize(incoming_lab_collection, lab_ids = [])
#lab_ids = lab_ids
#lab_collection = incoming_lab_collection
end
def perform
remove_associated_labs
add_labs
end
private
def add_labs
#lab_ids.each do |x|
lab = Lab.find(x)
LabCollectionInclusionAdder.new(#lab_collection, lab).perform
end
end
def remove_associated_labs
#lab_collection.lab_collection_inclusions.map(&:destroy)
#lab_collection.lab_collection_labs.map(&:destroy)
end
end
end
If you create an instance in a before hook or in a spec and then perform some database related work on it the instance you have will no longer reference the up to date information.
Try reloading the populated_lab_collection before asserting.
expect(populated_lab_collection.reload.labs).not_to eq labs
I have models in my Rails app:
Sales_Opportunity which has_many Swots.
I'm setting them up using FactoryGirl and running a test to show that when I delete my Sales_Opportunity I also cause the associated Swots to be deleted. For some reason when debugging with Byebug I'm getting strange results - the Sales_Opportunity and Swot records are being correctly created, but when I run sales_opportunity.swots it returns [ ] whereas sales_opportunity.swots.count returns 1. What's more odd is the exact same code works fine with anther object association (timeline_events are exactly like swots yet work perfectly with the same code).
Can anyone tell me what am I doing wrong please?
Sales_Opportunity.rb:
class SalesOpportunity < ActiveRecord::Base
default_scope { order('close_date ASC') }
belongs_to :user
belongs_to :company
has_many :key_contacts
has_many :timeline_events, dependent: :destroy
has_many :swots, dependent: :destroy
end
Swot.rb:
class Swot < ActiveRecord::Base
belongs_to :sales_opportunity
validates :swot_details, presence: true
validates :sales_opportunity_id, presence: true
enum swot_type: [:strength, :weakness, :opportunity, :threat]
enum swot_importance: { minimal: 1, marginal: 2, noteworthy: 3, significant: 4, critical: 5 }
validates :swot_importance, presence: true
end
Swot FactoryGirl spec:
FactoryGirl.define do
factory :swot do
swot_importance "minimal"
swot_details "Some boring details"
swot_type "threat"
trait :attached do
association :sales_opportunity, :with_user_id
end
end
end
Sales_Opportunity FactoryGirl spec:
FactoryGirl.define do
sequence(:opportunity_name) { |n| "Sales Oppotunity - #{n}" }
factory :sales_opportunity do
user
opportunity_name {generate(:opportunity_name)}
close_date "2014/12/12"
sale_value 10000
company_id 7
trait :with_user_id do
user_id 6
end
end
end
Failing Rspec tests:
describe "when swot's parent sales opportunity is destroyed" do
let(:swot) { FactoryGirl.create(:swot, :attached) }
let(:sales_opportunity) { swot.sales_opportunity }
it "should destroy associated swots" do
dswots = sales_opportunity.swots.to_a
byebug
sales_opportunity.destroy
expect(dswots).not_to be_empty
dswots.each do |dswot|
expect(Swot.where(id: dswot.id)).to be_empty
end
end
end
Output from the console (byebug) when logging swot:
#<Swot id: 13, swot_type: 3, swot_importance: 1, sales_opportunity_id: 564, swot_details: "Some boring details", created_at: "2015-07-27 10:57:23", updated_at: "2015-07-27 10:57:23">
Output from the console when logging sales_opportunity:
#<SalesOpportunity id: 564, close_date: "2014-12-12 00:00:00", user_id: 6, created_at: "2015-07-27 10:57:23", updated_at: "2015-07-27 10:57:23", pipeline_status: 0, opportunity_name: "Sales Oppotunity - 4", company_id: 7, sale_value: #<BigDecimal:7fe9ffd25078,'0.1E5',9(27)>, swot_score: 0>
Output for sales_opportunity.swots.count:
(byebug) sales_opportunity.swots.count
1
Output for sales_opportunity.swots:
(byebug) sales_opportunity.swots
#<ActiveRecord::Associations::CollectionProxy []>
I think I've included all the known info. The Rspec tests, FactoryGirl factories and setup between sales_opportunities and Swots/Timeline_Events is exactly the same - yet the Rspec tests pass for Timeline_Events and the collection_proxy works for those (so as far as I can tell, the code is identical):
Timeline_Event Factory:
FactoryGirl.define do
factory :timeline_event do
activity "Some activity"
due_date "2014/11/11"
trait :attached do
association :sales_opportunity, :with_user_id
end
end
end
Working Rspec tests:
describe "when sales opportunity is destroyed for timeline event" do
let(:timeline_event) { FactoryGirl.create(:timeline_event, :attached) }
let(:sales_opportunity) { timeline_event.sales_opportunity }
it "should destroy associated timeline events" do
timeline_events = sales_opportunity.timeline_events.to_a
sales_opportunity.destroy
expect(timeline_events).not_to be_empty
timeline_events.each do |event|
expect(TimelineEvent.where(id: event.id)).to be_empty
end
end
end
Timeline_Event.rb:
class TimelineEvent < ActiveRecord::Base
belongs_to :sales_opportunity
validates :activity, presence: true
validates :due_date, presence: true
validates :sales_opportunity_id, presence: true
end
When running byebug in the same place here I get an array including the Timeline_Event.
Can anyone help me understand what's going wrong in my code?
Thanks.
I solved the issue with this - it seems the sales_opportunity needs to be reloaded for the Active Record Associations to be persisted. This answer is the key to the solution.
Here's the working code:
describe "when swot's parent sales opportunity is destroyed" do
let!(:swot) { FactoryGirl.create(:swot, sales_opportunity: sales_opportunity) }
it "should destroy associated swots" do
sales_opportunity.reload
expect { sales_opportunity.destroy }.to change(sales_opportunity.swots, :count).by(-1)
end
end
Using some elements of Max's answer above also helped me improve the look and feel of the code.
RSpec.describe SalesOpportunity, type: :model do
let(:sales_opportunity) { create(:sales_opportunity) }
describe "swots" do
let!(:swot) { create(:swot, sales_opportunity: sales_opportunity) }
it "destroys nested swots" do
sales_opportunity.destroy
swot.reload
expect(swot.destroyed?).to be_truthy
end
end
end
Note that i'm adding this to the SalesOpportunity spec, because the dependant destroy behavior belongs to SalesOpportunity not the child association.
Edit.
Another way to write this spec would be:
it "destroys nested swots" do
expect {
sales_opportunity.destroy
}.to change(Swot, :count).by(-1)
end
I have two different objects which can belong to one parent object. These child objects can both also belong to each other (many to many). What's the best way to ensure that child objects which belong to each other also belong to the same parent object.
As an example of what I'm trying to do I have a Kingdom which has both many People and Land. The People model would have a custom validate which checks each related Land and error.adds if one has a mismatched kingdom_id. The Land model would have a similar validate.
This seems to work, but when updating it allows the record to save the 'THIS IS AN ERROR' error is in people.errors, however the Land which raised the error has been added to the People collection.
kingdom = Kingdom.create
people = People.create(:kingdom => kingdom)
land = Land.create(:kingdom_id => 999)
people.lands << land
people.save
puts people.errors.inspect # #messages={:base=>["THIS IS AN ERROR"]
puts people.lands.inspect # [#<Land id: 1...
Ideally I'd want the error to cancel the record update. Is there another way I should be going about this, or am I going in the wrong direction entirely?
# Models:
class Kingdom < ActiveRecord::Base
has_many :people
has_many :lands
end
class People < ActiveRecord::Base
belongs_to :kingdom
has_and_belongs_to_many :lands
validates :kingdom_id, :presence => true
validates :kingdom, :associated => true
validate :same_kingdom?
private
def same_kingdom?
if self.lands.any?
errors.add(:base, 'THIS IS AN ERROR') unless kingdom_match
end
end
def kingdom_match
self.lands.each do |l|
if l.kingdom_id != self.kingdom_id
return false
end
end
end
end
class Land < ActiveRecord::Base
belongs_to :kingdom
has_and_belongs_to_many :people
end
Firstly, the validation won't prevent the record from being added to the model's unpersisted collection. It will prevent the revised collection from being persisted to the database. So the model will be in an invalid state, and flagged as such with the appropriate errors. To see this, you can simply reload the people object.
You also have an error in your logic - the kingdom_match method will never return true even if no invalid kingdom_id's are found. You should add a line to fix this:
def kingdom_match
self.lands.each do |l|
return false if l.kingdom_id != self.kingdom_id
end
true
end
And you can make this validation a bit more concise and skip the kingdom_match method entirely:
def same_kingdom?
if self.lands.any?{|l| l.kingdom_id != self.kingdom_id }
errors.add(:base, 'THIS IS AN ERROR')
end
end
I have a simple situation setup in order to learn testing with FactoryGirl. A Bank has many transactions. Each time a transaction is created, it should subtract the transaction amount from the bank's total.
Here is the code:
# Bank.rb - Has many transactions
class Bank < ActiveRecord::Base
has_many :transactions
end
# Transaction.rb - Belongs to a bank and should decrement the bank's total when created.
class Transaction < ActiveRecord::Base
belongs_to :bank
after_create :decrement_bank_amount
def decrement_bank_amount
bank.decrement!(:amount, amount) if bank
end
end
# factories.rb - Create default factories for testing. This is FactoryGirl 4 syntax
FactoryGirl.define do
factory :bank do
sequence(:name) { |n| 'Bank ' + n.to_s }
end
factory :transaction do
sequence(:title) { |n| 'Payment ' + n.to_s }
bank
end
end
# Transaction_spec.rb - Creates a bank and a transaction.
require 'spec_helper'
describe Transaction do
describe ".create" do
context "when a bank is set" do
it "decreases the bank's amount" do
bank = FactoryGirl.create(:bank, :amount => 1000) do |b|
b.transactions.create(:amount => 250)
end
bank.amount.to_i.should eq 750
end
end
end
end
The test keeps failing and the bank amount keeps returning 1000 instead of the expected 750. I'm stumped!
This test is failing because bank is fetched from the database and stored. The after_create callback modifies the record in the database, but the object in bank doesn't see that, and so isn't updated.
You're going to need to call reload on that object before checking the amount:
bank.reload
bank.amount.to_i.should == 750
Consider the following:
ScheduledSession ------> Applicant <------ ApplicantSignup
Points to note:
A ScheduledSession will exist in the system at all times; think of this as a class or course.
The intent here is to validate the ApplicantSignup model against an attribute on ScheduledSession during signups_controller#create
Associations
class ScheduledSession < ActiveRecord::Base
has_many :applicants, :dependent => :destroy
has_many :applicant_signups, :through => :applicants
#...
end
class ApplicantSignup < ActiveRecord::Base
has_many :applicants, :dependent => :destroy
has_many :scheduled_sessions, :through => :applicants
#...
end
class Applicant < ActiveRecord::Base
belongs_to :scheduled_session
belongs_to :applicant_signup
# TODO: enforce validations for presence
# and uniqueness constraints etc.
#...
end
SignupsController
Resources are RESTful, i.e. the #create action will have a path that's similar to /scheduled_sessions/:id/signups/new
def new
#session = ScheduledSession.find(params[:scheduled_session_id])
#signup = #session.signups.new
end
def create
#session = ScheduledSession.find(params[:scheduled_session_id])
#session.duration = (#session.end.to_time - #session.start.to_time).to_i
#signup = ApplicantSignup.new(params[:signup].merge(:sessions => [#session]))
if #signup.save
# ...
else
render :new
end
end
You'll notice I'm setting a virtual attribute above #session.duration to prevent Session from being considered invalid. The real 'magic' if you will happens in #signup = ApplicantSignup.new(params[:signup].merge(:sessions => [#session])) which now means that in the model I can select from self.scheduled_sessions and access the ScheduledSession this ApplicantSignup is being built against, even though at this very point in time, there is no record present in the join table.
Model validations for example look like
def ensure_session_is_upcoming
errors[:base] << "Cannot signup for an expired session" unless self.scheduled_sessions.select { |r| r.upcoming? }.size > 0
end
def ensure_published_session
errors[:base] << "Cannot signup for an unpublished session" if self.scheduled_sessions.any? { |r| r.published == false }
end
def validate_allowed_age
# raise StandardError, self.scheduled_sessions.inspect
if self.scheduled_sessions.select { |r| r.allowed_age == "adults" }.size > 0
errors.add(:dob_year) unless (dob_year.to_i >= Time.now.strftime('%Y').to_i-85 && dob_year.to_i <= Time.now.strftime('%Y').to_i-18)
# elsif ... == "children"
end
end
The above works quite well in development and the validations work as expected — but how does one test with with Factory Girl? I want unit tests to guarantee the business logic I've implemented after all — sure, this is after the fact but is still one way of going about TDD.
You'll notice I've got a commented out raise StandardError, self.scheduled_sessions.inspect in the last validation above — this returns [] for self.scheduled_sessions which indicates that my Factory setup is just not right.
One of Many Attempts =)
it "should be able to signup to a session" do
scheduled_session = Factory.build(:scheduled_session)
applicant_signup = Factory.build(:applicant_signup)
applicant = Factory.create(:applicant, :scheduled_session => scheduled_session, :applicant_signup => applicant_signup)
applicant_signup.should be_valid
end
it "should be able to signup to a session for adults if between 18 and 85 years" do
scheduled_session = Factory.build(:scheduled_session)
applicant_signup = Factory.build(:applicant_signup)
applicant_signup.dob_year = 1983 # 28-years old
applicant = Factory.create(:applicant, :scheduled_session => scheduled_session, :applicant_signup => applicant_signup)
applicant_signup.should have(0).error_on(:dob_year)
end
The first one passes, but I honestly do not believe it's properly validating the applicant_signup model; the fact that self.scheduled_sessions is returning [] simply means that the above just isn't right.
It's quite possible that I'm trying to test something outside the scope of Factory Girl, or is there a far better approach to tackling this? Appreciate all comments, advice and constructive criticism!
Updates:
Not sure what this is called but this is the approach taken at least with regards to how it's implemented at the controller level
I need to consider ignoring Factory Girl for the association aspect at least and attempt to return the scheduled_session by mocking scheduled_sessions on the applicant_signup model.
Factories
FactoryGirl.define do
factory :signup do
title "Mr."
first_name "Franklin"
middle_name "Delano"
last_name "Roosevelt"
sequence(:civil_id) {"#{'%012d' % Random.new.rand((10 ** 11)...(10 ** 12))}"}
sequence(:email) {|n| "person#{n}##{(1..100).to_a.sample}example.com" }
gender "male"
dob_year "1980"
sequence(:phone_number) { |n| "#{'%08d' % Random.new.rand((10 ** 7)...(10 ** 8))}" }
address_line1 "some road"
address_line2 "near a pile of sand"
occupation "code ninja"
work_place "Dharma Initiative"
end
factory :session do
title "Example title"
start DateTime.civil_from_format(:local,2011,12,27,16,0,0)
duration 90
language "Arabic"
slides_language "Arabic & English"
venue "Main Room"
audience "Diabetic Adults"
allowed_age "adults"
allowed_gender "both"
capacity 15
published true
after_build do |session|
# signups will be assigned manually on a per test basis
# session.signups << FactoryGirl.build(:signup, :session => session)
end
end
factory :applicant do
association :session
association :signup
end
#...
end
My earlier assumption was correct, with on small change:
I need to consider ignoring Factory Girl for the association aspect at
least and attempt to return the scheduled_session by stubbing
scheduled_sessions on the applicant_signup model.
making my tests quite simply:
it "should be able to applicant_signup to a scheduled_session" do
scheduled_session = Factory(:scheduled_session)
applicant_signup = Factory.build(:applicant_signup)
applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
applicant_signup.should be_valid
end
it "should be able to applicant_signup to a scheduled_session for adults if between 18 and 85 years" do
scheduled_session = Factory(:scheduled_session)
applicant_signup = Factory.build(:applicant_signup)
applicant_signup.dob_year = 1983 # 28-years old
applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
applicant_signup.should have(0).error_on(:dob_year)
applicant_signup.should be_valid
end
and this test in particular required a similar approach:
it "should not be able to applicant_signup if the scheduled_session capacity has been met" do
scheduled_session = Factory.build(:scheduled_session, :capacity => 3)
scheduled_session.stub_chain(:applicant_signups, :count).and_return(3)
applicant_signup = Factory.build(:applicant_signup)
applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
applicant_signup.should_not be_valid
end
...and success — ignore the testing duration as spork causes false reporting of this.
Finished in 2253.64 seconds
32 examples, 0 failures, 3 pending
Done.
As another approach you could use Rspecs stub_model.
Also, if you test ApplicantSignup, you should init it and not test the creation of the Applicant. Eg:
applicant_signup = Factory.build(:applicant_signup);
applicant_signup.should_receive(:scheduled_sessions)
.and_return{[scheduled_session]};
So there will be less DB access and you will test ApplicantSignup, not Applicant.