diff --git a/Gemfile b/Gemfile index 5086e0c..81344e2 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,7 @@ gem 'devise-bootstrap-views', '~> 1.1' # https://github.com/hisea/devise-bootstr gem 'activestorage-validator' # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] # https://github.com/tzinfo/tzinfo-data +gem 'composite_primary_keys' # # Packaged JS, CSS libraries and helpers diff --git a/Gemfile.lock b/Gemfile.lock index 732f451..4fdd319 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,8 @@ GEM xpath (~> 3.2) childprocess (3.0.0) coderay (1.1.3) + composite_primary_keys (12.0.6) + activerecord (~> 6.0.0) concurrent-ruby (1.1.7) crass (1.0.6) debug_inspector (1.0.0) @@ -305,6 +307,7 @@ DEPENDENCIES bootstrap (~> 4.5, >= 4.5.2) byebug capybara (~> 3.33) + composite_primary_keys devise (~> 4.7, >= 4.7.3) devise-bootstrap-views (~> 1.1) devise-i18n (~> 1.9, >= 1.9.2) diff --git a/app/models/friendship.rb b/app/models/friendship.rb index ac86b4d..436f884 100644 --- a/app/models/friendship.rb +++ b/app/models/friendship.rb @@ -1,14 +1,19 @@ # A class for handling contacts which ensures that the association is always symmetrical class Friendship < ApplicationRecord self.table_name = 'users_users' - belongs_to :user, foreign_key: :contact_id, inverse_of: :friendships + self.primary_keys = :user_id, :contact_id + + belongs_to :user, inverse_of: :friendships + belongs_to :contact, class_name: 'User', inverse_of: :friendships after_create do |c| - Friendship.create!(user_id: c.contact_id, contact_id: c.user_id) unless Friendship.find_by(contact_id: c.user_id) + unless Friendship.find_by(user_id: c.contact_id, contact_id: c.user_id) + Friendship.create(user_id: c.contact_id, contact_id: c.user_id) + end end after_destroy do |c| - reciprocal = Friendship.find_by(contact_id: c.user_id) + reciprocal = Friendship.find_by(contact_id: c.user_id, user_id: c.contact_id) reciprocal&.destroy end end diff --git a/app/models/user.rb b/app/models/user.rb index 6c5f301..02b9af1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,8 +15,8 @@ class User < ApplicationRecord has_many :activities, dependent: :delete_all has_many :call_participants, dependent: :delete_all, inverse_of: :user has_many :jitsi_calls, through: :call_participants - has_many :friendships, dependent: :destroy - has_many :contacts, through: :friendships, source: :user + has_many :friendships, dependent: :destroy, inverse_of: :user + has_many :contacts, through: :friendships # as we do not need to work with the relationship models as independent entities, `has_and_belongs_to_many` is fine # https://guides.rubyonrails.org/association_basics.html#choosing-between-has-many-through-and-has-and-belongs-to-many diff --git a/spec/models/friendship_spec.rb b/spec/models/friendship_spec.rb new file mode 100644 index 0000000..7d2b7ab --- /dev/null +++ b/spec/models/friendship_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe Friendship, type: :model do + let(:users) { FactoryBot.create_list :user, 3 } + + before { described_class.create(user_id: users[0].id, contact_id: users[1].id) } + + it 'creates a symmetrical association' do + expect(described_class.find_by(user_id: users[0].id, contact_id: users[1].id)).to be_present + expect(described_class.find_by(user_id: users[1].id, contact_id: users[0].id)).to be_present + end + + it 'deletes both parts of the association' do + described_class.find_by(user_id: users[0].id, contact_id: users[1].id).destroy! + expect(described_class.find_by(user_id: users[0].id, contact_id: users[1].id)).to be_blank + expect(described_class.find_by(user_id: users[1].id, contact_id: users[0].id)).to be_blank + end + + describe 'multiple contacts' do + before { described_class.create(user_id: users[0].id, contact_id: users[2].id) } + + it 'allows a user to have multiple contacts' do + expect(described_class.find_by(user_id: users[0].id, contact_id: users[1].id)).to be_present + expect(described_class.find_by(user_id: users[0].id, contact_id: users[2].id)).to be_present + end + + it 'allows to only delete one of the contacts' do + described_class.find_by(user_id: users[0].id, contact_id: users[1].id).destroy! + expect(described_class.find_by(user_id: users[0].id, contact_id: users[1].id)).to be_blank + expect(described_class.find_by(user_id: users[0].id, contact_id: users[2].id)).to be_present + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 699841d..d68be20 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -164,7 +164,30 @@ end it 'destroys all participants when destroyed' do - expect { user.destroy }.to change(CallParticipant, :count).from(jitsi_calls.size).to(0) + expect { user.destroy }.to change(CallParticipant, :count).by(-jitsi_calls.size) + end + end + + context 'with contacts' do + let(:contacts) { FactoryBot.create_list :user, 2 } + + before do + user.save + contacts.each { |contact| user.friendships.create(contact: contact) } + end + + it 'has associated contacts' do + expect(user.contacts).to include(*contacts) + end + + it 'has friendships' do + expect(user.friendships.map(&:contact_id)).to include(*contacts.map(&:id)) + end + + it 'destroys all friendships when destroyed' do + user.destroy + expect(Friendship.where(user_id: user.id)).to be_empty + expect(Friendship.where(contact_id: user.id)).to be_empty end end end