Skip to content

Instantly share code, notes, and snippets.

@inopinatus
Last active March 22, 2022 00:30
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save inopinatus/ee6dcc3e0c5cc1ab4d8a6cdeb395175d to your computer and use it in GitHub Desktop.
Save inopinatus/ee6dcc3e0c5cc1ab4d8a6cdeb395175d to your computer and use it in GitHub Desktop.
Validating uniqueness of an association record with external scope
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "6.0.0"
gem "sqlite3"
gem "pry"
end
require "minitest/autorun"
require "active_record"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT) if $VERBOSE
ActiveRecord::Schema.define do
create_table :variants, force: true do |t|
t.string :sku
end
create_table :choices, force: true do |t|
t.string :name
t.references :option
end
create_table :selections, force: true do |t|
t.references :variant
t.references :choice
end
create_table :options, force: true do |t|
t.string :name
end
end
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
##########################################################################################
# Begin application code under test
##########################################################################################
# no change
class Choice < ApplicationRecord
has_many :variants, through: :selections
belongs_to :option
end
# no change, looks incomplete though?
class Option < ApplicationRecord
has_many :choices, dependent: :destroy
accepts_nested_attributes_for :choices, allow_destroy: true
end
# not really changed
class Variant < ApplicationRecord
has_many :selections
has_many :choices, through: :selections
end
class Selection < ApplicationRecord
belongs_to :choice
belongs_to :variant
validates_absence_of :conflicting_selection
protected
def option
choice.option
end
def conflicting_selection
variant.selections.excluding(self).detect { |other| option == other.option }
end
end
##########################################################################################
# End application code under test
##########################################################################################
class VariantsTest < Minitest::Test
def setup
@opts = {
size: Option.create!(name: "size"),
color: Option.create!(name: "color")
}
@size = {
small: @opts[:size].choices.create!(name: "small"),
medium: @opts[:size].choices.create!(name: "medium"),
large: @opts[:size].choices.create!(name: "large")
}
@color = {
red: @opts[:color].choices.create!(name: "red"),
green: @opts[:color].choices.create!(name: "green"),
blue: @opts[:color].choices.create!(name: "blue")
}
end
def test_good_variant_create
variant = Variant.create! sku: "fuzz-001", choices: [@size[:large], @color[:blue]]
assert_equal 1, Variant.count
assert_equal 2, variant.selections.count
variant.destroy
end
def test_parallel_overlapping_variants
variant1 = Variant.create! sku: "fuzz-001A", choices: [@size[:small], @color[:blue]]
variant2 = Variant.create! sku: "fuzz-001B", choices: [@size[:small], @color[:green]]
assert_equal 2, Variant.count
variant1.reload
variant2.reload
assert variant1.valid?
assert variant2.valid?
assert_equal %w(blue small), variant1.choices.map(&:name).sort
assert_equal %w(green small), variant2.choices.map(&:name).sort
variant1.destroy
variant2.destroy
end
def test_bad_variant_create
assert_raises(ActiveRecord::RecordInvalid) {
Variant.create! sku: "fuzz-002", choices: [@size[:large], @color[:blue], @color[:green]]
}
end
def test_from_good_to_bad_persisted
variant = Variant.create! sku: "fuzz-003", choices: [@size[:medium], @color[:red]]
assert_raises(ActiveRecord::RecordInvalid) {
variant.choices << @size[:small]
}
variant.destroy
end
def test_errors_on_create
variant = Variant.create sku: "fuzz-004", choices: [@size[:large], @color[:blue], @color[:green]]
assert variant.new_record?
assert variant.invalid?
assert_equal :invalid, variant.errors.details.dig(:selections, 0, :error)
selections_error_details = variant.selections.map(&:errors).map(&:details)
assert_equal 2, selections_error_details.count(conflicting_selection: [error: :present])
end
def test_good_variant_new
variant = Variant.new sku: "fuzz-005", choices: [@size[:large], @color[:blue]]
assert variant.valid?
end
def test_bad_variant_new
variant = Variant.new sku: "fuzz-006", choices: [@size[:large], @color[:blue], @color[:green]]
assert variant.invalid?
end
def test_from_good_to_bad_unpersisted
variant = Variant.new sku: "fuzz-007", choices: [@size[:medium], @color[:red]]
assert variant.valid?
variant.choices << @size[:small]
assert variant.invalid?
end
def test_errors_on_validate
variant = Variant.new sku: "fuzz-008", choices: [@size[:large], @color[:blue], @color[:green]]
assert !variant.validate
assert_equal :invalid, variant.errors.details.dig(:selections, 0, :error)
selections_error_details = variant.selections.map(&:errors).map(&:details)
assert_equal 2, selections_error_details.count(conflicting_selection: [error: :present])
end
def test_updates
variant = Variant.create! sku: "fuzz-009", choices: [@size[:small], @color[:red]]
variant.update(choices: [@size[:medium], @color[:blue]])
assert variant.valid?
assert_equal %w(blue medium), variant.choices.map(&:name).sort
variant.reload # throw away association cache
assert variant.valid?
assert_equal %w(blue medium), variant.choices.map(&:name).sort
assert_raises(ActiveRecord::RecordInvalid) {
variant.choices = [@size[:large], @color[:green], @color[:red]]
}
variant.destroy
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment