Devon Kiss
Devon Kiss

Reputation: 239

Ruby on Rails RSpec test for model to only allow one record in database

I am trying to build an RSpec test spec for my model: Logo that will ensure that only a singular record can be saved to the database. When I utilize the .build method for the second call to build a Logo, my test fails because FactoryBot is able to build out a Logo.

However, if I use the .create method for the second Logo entry in FactoryBot I receive an error for the test because my model raises an error, as instructed, based upon my model's method for the :only_one_row method.

How can I make this work using RSpec and FactoryBot?

Here is the code I have tried, unsuccessfully:

# app/models/logo.rb
class Logo < ApplicationRecord
  before_create :only_one_row

  private

  def only_one_row
    raise "You can only have one logo file for this website application" if Logo.count > 0
  end
end
# spec/factories/logos.rb
FactoryBot.define do
  factory :logo do
    image { File.open(File.join(Rails.root, 'spec', 'fixtures', 'example_image.jpg')) }
  end
end
# spec/logo_spec.rb
require 'rails_helper'

RSpec.describe Logo, type: :model do
  it 'can be created' do
    example_logo = FactoryBot.create(:logo)
    expect(example_logo).to be_valid
  end

  it 'can not have more than one record' do
    # Ensure there are no logo records in the database before this test is run.
    Logo.destroy_all

    example_logo_one = FactoryBot.create(:logo)
    # This is where the trouble lies...
    # If I go with .create method I error with the raised error defined in my model file...
    example_logo_two = FactoryBot.create(:logo)
    # ... if I go with the .build method I receive an error as the .build method succeeds
    # example_logo_two = FactoryBot.build(:logo)
    
    expect(example_logo_two).to_not be_valid
  end
end

Upvotes: 0

Views: 1647

Answers (3)

Clemens Kofler
Clemens Kofler

Reputation: 1968

Two things here:

If your whole application only ever allows a single logo at all (and not, say, a single logo per company, per user or whatever), then I don't think there's a reason to put it in the database. Instead, simply put it in the filesystem and be done with it.

If there is a good reason to have it in the database despite my previous comment and you really want to make sure that there's only ever one logo, I would very much recommend to set this constraint on a database level. The two ways that come to mind is to revoke INSERT privileges for the relevant table or to define a trigger that prevents INSERT queries if the table already has a record. This approach is critical because it's easily forgotten that 1) validations can be purposefully or accidentally circumvented (save(validate: false), update_column etc.) and 2) the database can be accessed by clients other than your app (such as another app, the database's own console tool etc.). If you want to ensure data integrity, you have to do such elemental things on a database level.

Upvotes: 1

Schwern
Schwern

Reputation: 165586

When I utilize the .build method for the second call to build a Logo, my test fails because FactoryBot is able to build out a Logo.

That is correct, build does not save the object.

However, if I use the .create method for the second Logo entry in FactoryBot I receive an error for the test because my model raises an error, as instructed, based upon my model's method for the :only_one_row method.

Catch the exception with an expect block and the raise_error matcher.

context 'with one Logo already saved' do
  let!(:logo) { create(:logo) }

  it 'will not allow another' do
    expect {
      create(:logo)
    }.to raise_error("You can only have one logo file for this website application")
  end
end

Note this must hard code the exception message into the test. If the message changes, the test will fail. You could test for RuntimeError, but any RuntimeError would pass the test.

To avoid this, create a subclass of RuntimeError, raise that, and test for that specific exception.

class Logo < ApplicationRecord
  ...

  def only_one_row
    raise OnlyOneError if Logo.count > 0
  end

  class OnlyOneError < RuntimeError
    MESSAGE = "You can only have one logo file for this website application".freeze
    def initialize(msg = MESSAGE)
      super
    end
  end
end

Then you can test for that exception.

expect {
  create(:logo)
}.to raise_error(Logo::OnlyOneError)

Note that Logo.destroy_all should be unnecessary if you have your tests and test database set up correct. Each test example should start with a clean, empty database.

Upvotes: 2

Robert Nubel
Robert Nubel

Reputation: 7532

Your validation here is implemented as a hook, not a validation, which is why the be_valid call will never fail. I want to note, there's no real issue here from a logical perspective -- a hard exception as a sanity check seems acceptable in this situation, since it shouldn't be something the app is trying to do. You could even re-write your test to test for it explicitly:

 it 'can not have more than one record' do
    # Ensure there are no logo records in the database before this test is run.
    Logo.destroy_all

    example_logo_one = FactoryBot.create(:logo)

    expect { FactoryBot.create(:logo) }.to raise_error(RuntimeError)
  end

But, if there's a possibility the app might try it and you want a better user experience, you can build this as a validation. The tricky part there is that the validation looks different for an unsaved Logo (we need to make sure there are no other saved Logos, period) versus an existing one (we just need to validate that we're the only one). We can make it one single check just by making sure that there are no Logos out there that aren't this one:

class Logo < ApplicationRecord
  validate do |logo|
    if Logo.first && Logo.first != logo
      logo.errors.add(:base, "You can only have one logo file for this website application")
    end
  end
end

This validation will allow the first logo to save, but should immediately know that the second logo is invalid, passing your original spec.

Upvotes: 2

Related Questions