Refactor family invitations and memberships into separate models and controllers

This commit is contained in:
Eugene Burmakin 2025-10-07 18:38:06 +02:00
parent 6fb5d98b19
commit e711ff25fe
37 changed files with 336 additions and 245 deletions

View file

@ -1,2 +1,3 @@
release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml

View file

@ -5,11 +5,6 @@
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
],
"scripts": {
"dokku": {
"predeploy": "bundle exec rails db:migrate"
}
},
"healthchecks": {
"web": [
{

View file

@ -3,9 +3,8 @@
class Family::InvitationsController < ApplicationController
before_action :authenticate_user!, except: %i[show]
before_action :ensure_family_feature_enabled!, except: %i[show]
before_action :set_family, except: %i[show accept]
before_action :set_family, except: %i[show]
before_action :set_invitation_by_id_and_family, only: %i[destroy]
before_action :set_invitation_by_id, only: %i[accept]
def index
authorize @family, :show?
@ -14,7 +13,7 @@ class Family::InvitationsController < ApplicationController
end
def show
@invitation = FamilyInvitation.find_by!(token: params[:token])
@invitation = Family::Invitation.find_by!(token: params[:token])
if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.' and return
@ -41,34 +40,6 @@ class Family::InvitationsController < ApplicationController
end
end
def accept
unless @invitation.pending?
redirect_to root_path, alert: 'This invitation has already been processed' and return
end
if @invitation.expired?
redirect_to root_path, alert: 'This invitation is no longer valid or has expired' and return
end
if @invitation.email != current_user.email
redirect_to root_path, alert: 'This invitation is not for your email address' and return
end
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
redirect_to family_path, notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end
rescue StandardError => e
Rails.logger.error "Error accepting family invitation: #{e.message}"
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
end
def destroy
authorize @family, :manage_invitations?
@ -92,10 +63,6 @@ class Family::InvitationsController < ApplicationController
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
end
def set_invitation_by_id
@invitation = FamilyInvitation.find_by!(token: params[:id])
end
def set_invitation_by_id_and_family
# For authenticated nested routes: /families/:family_id/invitations/:id
# The :id param contains the token value

View file

@ -3,8 +3,37 @@
class Family::MembershipsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_family_feature_enabled!
before_action :set_family
before_action :set_family, except: %i[create]
before_action :set_membership, only: %i[destroy]
before_action :set_invitation, only: %i[create]
def create
unless @invitation.pending?
redirect_to root_path, alert: 'This invitation has already been processed' and return
end
if @invitation.expired?
redirect_to root_path, alert: 'This invitation is no longer valid or has expired' and return
end
if @invitation.email != current_user.email
redirect_to root_path, alert: 'This invitation is not for your email address' and return
end
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
redirect_to family_path, notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end
rescue StandardError => e
Rails.logger.error "Error accepting family invitation: #{e.message}"
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
end
def destroy
authorize @membership
@ -34,4 +63,8 @@ class Family::MembershipsController < ApplicationController
def set_membership
@membership = @family.family_memberships.find(params[:id])
end
def set_invitation
@invitation = Family::Invitation.find_by!(token: params[:token])
end
end

View file

@ -49,7 +49,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def set_invitation
return unless invitation_token.present?
@invitation = FamilyInvitation.find_by(token: invitation_token)
@invitation = Family::Invitation.find_by(token: invitation_token)
end
def self_hosted_mode?

View file

@ -11,7 +11,7 @@ class Users::SessionsController < Devise::SessionsController
def after_sign_in_path_for(resource)
if invitation_token.present?
invitation = FamilyInvitation.find_by(token: invitation_token)
invitation = Family::Invitation.find_by(token: invitation_token)
if invitation&.can_be_accepted?
return family_invitation_path(invitation.token)
@ -26,7 +26,7 @@ class Users::SessionsController < Devise::SessionsController
def load_invitation_context
return unless invitation_token.present?
@invitation = FamilyInvitation.find_by(token: invitation_token)
@invitation = Family::Invitation.find_by(token: invitation_token)
end
def invitation_token

View file

@ -6,14 +6,14 @@ class FamilyInvitationsCleanupJob < ApplicationJob
def perform
Rails.logger.info 'Starting family invitations cleanup'
expired_count = FamilyInvitation.where(status: :pending)
expired_count = Family::Invitation.where(status: :pending)
.where('expires_at < ?', Time.current)
.update_all(status: :expired)
Rails.logger.info "Updated #{expired_count} expired family invitations"
cleanup_threshold = 30.days.ago
deleted_count = FamilyInvitation.where(status: [:expired, :cancelled])
deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
.where('updated_at < ?', cleanup_threshold)
.delete_all

View file

@ -6,10 +6,10 @@ class FamilyMailer < ApplicationMailer
@family = invitation.family
@invited_by = invitation.invited_by
@accept_url = family_invitation_url(@invitation.token)
pp @accept_url
mail(
to: @invitation.email,
subject: "You've been invited to join #{@family.name} on Dawarich"
subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
)
end
end

View file

@ -4,11 +4,10 @@ module UserFamily
extend ActiveSupport::Concern
included do
# Family associations
has_one :family_membership, dependent: :destroy
has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership'
has_one :family, through: :family_membership
has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id',
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
inverse_of: :invited_by, dependent: :destroy
before_destroy :check_family_ownership
@ -30,25 +29,14 @@ module UserFamily
end
def family_sharing_enabled?
# User must be in a family and have explicitly enabled location sharing
return false unless in_family?
sharing_settings = settings.dig('family', 'location_sharing')
return false if sharing_settings.blank?
# If it's a boolean (legacy support), return it
return sharing_settings if [true, false].include?(sharing_settings)
# If it's time-limited sharing, check if it's still active
if sharing_settings.is_a?(Hash)
return false unless sharing_settings.is_a?(Hash)
return false unless sharing_settings['enabled'] == true
# Check if sharing has an expiration
expires_at = sharing_settings['expires_at']
return expires_at.blank? || Time.parse(expires_at) > Time.current
end
false
expires_at.blank? || Time.parse(expires_at).future?
end
def update_family_location_sharing!(enabled, duration: nil)
@ -60,21 +48,14 @@ module UserFamily
if enabled
sharing_config = { 'enabled' => true }
# Add expiration if duration is specified
if duration.present?
expiration_time = case duration
when '1h'
1.hour.from_now
when '6h'
6.hours.from_now
when '12h'
12.hours.from_now
when '24h'
24.hours.from_now
when 'permanent'
nil # No expiration
else
duration.to_i.hours.from_now if duration.to_i > 0
when '1h' then 1.hour.from_now
when '6h' then 6.hours.from_now
when '12h' then 12.hours.from_now
when '24h' then 24.hours.from_now
when 'permanent' then nil
else duration.to_i.hours.from_now if duration.to_i > 0
end
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
@ -106,8 +87,8 @@ module UserFamily
def latest_location_for_family
return nil unless family_sharing_enabled?
# Use select to only fetch needed columns and limit to 1 for efficiency
latest_point = points.select(:latitude, :longitude, :timestamp)
latest_point =
points.select(:lonlat, :timestamp)
.order(timestamp: :desc)
.limit(1)
.first
@ -117,10 +98,10 @@ module UserFamily
{
user_id: id,
email: email,
latitude: latest_point.latitude,
longitude: latest_point.longitude,
latitude: latest_point.lat,
longitude: latest_point.lon,
timestamp: latest_point.timestamp,
updated_at: Time.at(latest_point.timestamp)
updated_at: Time.zone.at(latest_point.timestamp)
}
end

View file

@ -1,9 +1,9 @@
# frozen_string_literal: true
class Family < ApplicationRecord
has_many :family_memberships, dependent: :destroy
has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership'
has_many :members, through: :family_memberships, source: :user
has_many :family_invitations, dependent: :destroy
has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation'
belongs_to :creator, class_name: 'User'
validates :name, presence: true, length: { maximum: 50 }

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Family::Invitation < ApplicationRecord
self.table_name = 'family_invitations'
EXPIRY_DAYS = 7
belongs_to :family
belongs_to :invited_by, class_name: 'User'
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token, presence: true, uniqueness: true
validates :expires_at, :status, presence: true
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
before_validation :generate_token, :set_expiry, on: :create
after_create :clear_family_cache
after_update :clear_family_cache, if: :saved_change_to_status?
after_destroy :clear_family_cache
def expired?
expires_at.past?
end
def can_be_accepted?
pending? && !expired?
end
private
def generate_token
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
end
def set_expiry
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
end
def clear_family_cache
family&.clear_member_cache!
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Family::Membership < ApplicationRecord
self.table_name = 'family_memberships'
belongs_to :family
belongs_to :user
validates :user_id, presence: true, uniqueness: true
validates :role, presence: true
enum :role, { owner: 0, member: 1 }
after_create :clear_family_cache
after_update :clear_family_cache
after_destroy :clear_family_cache
private
def clear_family_cache
family&.clear_member_cache!
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Family::InvitationPolicy < ApplicationPolicy
def show?
# Public endpoint for invitation acceptance - no authentication required
true
end
def create?
user.family == record.family && user.family_owner?
end
def accept?
# Users can accept invitations sent to their email
user.email == record.email
end
def destroy?
# Only family owners can cancel invitations
user.family == record.family && user.family_owner?
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Family::MembershipPolicy < ApplicationPolicy
def show?
user.family == record.family
end
def update?
# Users can update their own settings
return true if user == record.user
# Family owners can update any member's settings
user.family == record.family && user.family_owner?
end
def destroy?
# Users can remove themselves (handled by family leave logic)
return true if user == record.user
# Family owners can remove other members
user.family == record.family && user.family_owner?
end
end

View file

@ -65,7 +65,7 @@ module Families
end
def create_membership
FamilyMembership.create!(
Family::Membership.create!(
family: invitation.family,
user: user,
role: :member

View file

@ -81,7 +81,7 @@ module Families
end
def create_owner_membership
FamilyMembership.create!(
Family::Membership.create!(
family: family,
user: user,
role: :owner

View file

@ -73,7 +73,7 @@ module Families
end
def create_invitation
@invitation = FamilyInvitation.create!(
@invitation = Family::Invitation.create!(
family: family,
email: email,
invited_by: invited_by

View file

@ -121,7 +121,7 @@
<div class="space-y-4">
<% if user_signed_in? %>
<!-- User is logged in, show accept button -->
<%= link_to accept_family_invitation_path(@invitation.family, @invitation),
<%= link_to accept_family_invitation_path(token: @invitation.token),
method: :post,
class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %>
✓ Accept Invitation & Join Family

View file

@ -42,7 +42,7 @@
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
Best regards,<br>
The Dawarich Team
Evgenii from Dawarich
</p>
</div>
</div>

View file

@ -19,4 +19,4 @@ If you don't have a Dawarich account yet, you'll be able to create one when you
If you didn't expect this invitation, you can safely ignore this email.
Best regards,
The Dawarich Team
Evgenii from Dawarich

View file

@ -21,7 +21,7 @@
<% end %>
</div>
<% else %>
<%= link_to 'Family', new_family_path, class: "#{active_class?(new_family_path)}" %>
<%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %>
<% end %>
</li>
<% end %>
@ -79,14 +79,14 @@
<div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path, class: "mx-1 #{active_class?(family_path)} flex items-center space-x-2" do %>
<span>Family</span>
<span>Family<sup>α</sup></span>
<div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %>
</div>
<% else %>
<%= link_to 'Family', new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<% end %>
</li>
<% end %>

View file

@ -52,3 +52,4 @@
</form>
</dialog>
</div>
</div>

View file

@ -62,15 +62,12 @@ Rails.application.routes.draw do
resource :family, only: %i[show new create edit update destroy] do
patch :update_location_sharing, on: :member
resources :invitations, except: %i[edit update], controller: 'family/invitations' do
member do
post :accept
end
end
resources :invitations, except: %i[edit update], controller: 'family/invitations'
resources :members, only: %i[destroy], controller: 'family/memberships'
end
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation
end
resources :points, only: %i[index] do

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :family_invitation do
factory :family_invitation, class: 'Family::Invitation' do
association :family
association :invited_by, factory: :user
sequence(:email) { |n| "invite#{n}@example.com" }

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :family_membership do
factory :family_membership, class: 'Family::Membership' do
association :family
association :user
role { :member }

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe FamilyInvitation, type: :model do
RSpec.describe Family::Invitation, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:invited_by).class_name('User') }
@ -44,9 +44,9 @@ RSpec.describe FamilyInvitation, type: :model do
describe '.active' do
it 'returns only pending and non-expired invitations' do
expect(FamilyInvitation.active).to include(pending_invitation)
expect(FamilyInvitation.active).not_to include(expired_invitation)
expect(FamilyInvitation.active).not_to include(accepted_invitation)
expect(Family::Invitation.active).to include(pending_invitation)
expect(Family::Invitation.active).not_to include(expired_invitation)
expect(Family::Invitation.active).not_to include(accepted_invitation)
end
end
end
@ -63,7 +63,7 @@ RSpec.describe FamilyInvitation, type: :model do
it 'sets expiry date' do
invitation.save
expect(invitation.expires_at).to be_within(1.minute).of(FamilyInvitation::EXPIRY_DAYS.days.from_now)
expect(invitation.expires_at).to be_within(1.minute).of(Family::Invitation::EXPIRY_DAYS.days.from_now)
end
it 'does not override existing token' do
@ -136,7 +136,7 @@ RSpec.describe FamilyInvitation, type: :model do
describe 'constants' do
it 'defines EXPIRY_DAYS' do
expect(FamilyInvitation::EXPIRY_DAYS).to eq(7)
expect(Family::Invitation::EXPIRY_DAYS).to eq(7)
end
end

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe FamilyMembership, type: :model do
RSpec.describe Family::Membership, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:user) }

View file

@ -107,8 +107,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated invitations when family is destroyed' do
invitation = create(:family_invitation, family: family, invited_by: user)
expect { family.destroy }.to change(FamilyInvitation, :count).by(-1)
expect(FamilyInvitation.find_by(id: invitation.id)).to be_nil
expect { family.destroy }.to change(Family::Invitation, :count).by(-1)
expect(Family::Invitation.find_by(id: invitation.id)).to be_nil
end
end
@ -118,8 +118,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated memberships when family is destroyed' do
membership = create(:family_membership, family: family, user: user, role: :owner)
expect { family.destroy }.to change(FamilyMembership, :count).by(-1)
expect(FamilyMembership.find_by(id: membership.id)).to be_nil
expect { family.destroy }.to change(Family::Membership, :count).by(-1)
expect(Family::Membership.find_by(id: membership.id)).to be_nil
end
end
end

View file

@ -12,7 +12,7 @@ RSpec.describe User, 'family methods', type: :model do
is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy)
}
it {
is_expected.to have_many(:sent_family_invitations).class_name('FamilyInvitation').with_foreign_key('invited_by_id').dependent(:destroy)
is_expected.to have_many(:sent_family_invitations).class_name('Family::Invitation').with_foreign_key('invited_by_id').dependent(:destroy)
}
end
@ -119,7 +119,7 @@ RSpec.describe User, 'family methods', type: :model do
end
it 'destroys associated invitations when user is destroyed' do
expect { user.destroy }.to change(FamilyInvitation, :count).by(-1)
expect { user.destroy }.to change(Family::Invitation, :count).by(-1)
end
end
@ -129,7 +129,7 @@ RSpec.describe User, 'family methods', type: :model do
end
it 'destroys associated membership when user is destroyed' do
expect { user.destroy }.to change(FamilyMembership, :count).by(-1)
expect { user.destroy }.to change(Family::Membership, :count).by(-1)
end
end
end

View file

@ -69,7 +69,7 @@ RSpec.describe 'Family', type: :request do
it 'creates a family membership for the user' do
expect do
post '/family', params: valid_attributes
end.to change(FamilyMembership, :count).by(1)
end.to change(Family::Membership, :count).by(1)
end
it 'redirects to the new family with success message' do

View file

@ -92,7 +92,7 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'creates a new invitation' do
expect do
post "/family/invitations", params: valid_params
end.to change(FamilyInvitation, :count).by(1)
end.to change(Family::Invitation, :count).by(1)
end
it 'redirects with success message' do
@ -112,7 +112,7 @@ RSpec.describe 'Family::Invitations', type: :request do
invitation # create the existing invitation
expect do
post "/family/invitations", params: duplicate_params
end.not_to change(FamilyInvitation, :count)
end.not_to change(Family::Invitation, :count)
end
it 'redirects with error message' do
@ -161,81 +161,6 @@ RSpec.describe 'Family::Invitations', type: :request do
end
end
describe 'POST /family/invitations/:id/accept' do
let(:invitee) { create(:user) }
let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) }
context 'with valid invitation and user' do
before { sign_in invitee }
it 'accepts the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post "/family/invitations/#{invitee_invitation.token}/accept"
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
context 'when user is already in a family' do
let(:other_family) { create(:family) }
before do
create(:family_membership, user: invitee, family: other_family, role: :member)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end
end
context 'when invitation is expired' do
before do
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end
end
context 'when not authenticated' do
it 'redirects to login' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/invitations/:id' do
before { sign_in user }
@ -294,7 +219,7 @@ RSpec.describe 'Family::Invitations', type: :request do
}
expect(response).to redirect_to(family_path)
created_invitation = FamilyInvitation.last
created_invitation = Family::Invitation.last
expect(created_invitation.email).to eq(invitee.email)
# 2. Invitee views public invitation page
@ -304,7 +229,7 @@ RSpec.describe 'Family::Invitations', type: :request do
# 3. Invitee accepts invitation
sign_in invitee
post "/family/invitations/#{created_invitation.token}/accept"
post accept_family_invitation_path(token: created_invitation.token)
expect(response).to redirect_to(family_path)
# 4. Verify invitee is now in family

View file

@ -15,12 +15,89 @@ RSpec.describe 'Family::Memberships', type: :request do
sign_in user
end
describe 'POST /family/memberships' do
let(:invitee) { create(:user) }
let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) }
context 'with valid invitation and user' do
before { sign_in invitee }
it 'accepts the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post accept_family_invitation_path(token: invitee_invitation.token)
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
context 'when user is already in a family' do
let(:other_family) { create(:family) }
before do
create(:family_membership, user: invitee, family: other_family, role: :member)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end
end
context 'when invitation is expired' do
before do
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/members/:id' do
context 'when removing a regular member' do
it 'removes the member from the family' do
expect do
delete "/family/members/#{member_membership.id}"
end.to change(FamilyMembership, :count).by(-1)
end.to change(Family::Membership, :count).by(-1)
end
it 'redirects with success message' do
@ -41,7 +118,7 @@ RSpec.describe 'Family::Memberships', type: :request do
it 'does not remove the owner' do
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count)
end.not_to change(Family::Membership, :count)
end
it 'redirects with error message explaining owners must delete family' do
@ -56,7 +133,7 @@ RSpec.describe 'Family::Memberships', type: :request do
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count)
end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path)
follow_redirect!
@ -149,7 +226,7 @@ RSpec.describe 'Family::Memberships', type: :request do
# Try to remove owner - should be prevented
expect do
delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count)
end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path)
expect(user.reload.family).to eq(family)

View file

@ -52,7 +52,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 accepts invitation
sign_in user2
post "/family/invitations/#{invitation.token}/accept"
post accept_family_invitation_path(token: invitation.token)
expect(response).to redirect_to(family_path)
expect(user2.reload.family).to eq(family)
@ -71,7 +71,7 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 5: User3 accepts invitation
sign_in user3
post "/family/invitations/#{invitation2.token}/accept"
post accept_family_invitation_path(token: invitation2.token)
expect(user3.reload.family).to eq(family)
expect(family.reload.members.count).to eq(3)
@ -108,7 +108,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 tries to accept expired invitation
sign_in user2
post "/family/invitations/#{invitation.token}/accept"
post accept_family_invitation_path(token: invitation.token)
expect(response).to redirect_to(root_path)
expect(user2.reload.family).to be_nil
@ -127,12 +127,12 @@ RSpec.describe 'Family Workflows', type: :request do
it 'prevents users from joining multiple families' do
# User3 accepts invitation to Family 1
sign_in user3
post "/family/invitations/#{invitation1.token}/accept"
post accept_family_invitation_path(token: invitation1.token)
expect(response).to redirect_to(family_path)
expect(user3.family).to eq(family1)
# User3 tries to accept invitation to Family 2
post "/family/invitations/#{invitation2.token}/accept"
post accept_family_invitation_path(token: invitation2.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family')
@ -268,7 +268,7 @@ RSpec.describe 'Family Workflows', type: :request do
post "/family/invitations", params: {
family_invitation: { email: 'newuser@example.com' }
}
end.to change(FamilyInvitation, :count).by(1)
end.to change(Family::Invitation, :count).by(1)
invitation = family.family_invitations.find_by(email: 'newuser@example.com')
expect(invitation.email).to eq('newuser@example.com')

View file

@ -11,8 +11,8 @@ RSpec.describe Families::AcceptInvitation do
describe '#call' do
context 'when invitation can be accepted' do
it 'creates membership for user' do
expect { service.call }.to change(FamilyMembership, :count).by(1)
membership = invitee.family_membership
expect { service.call }.to change(Family::Membership, :count).by(1)
membership = invitee.reload.family_membership
expect(membership.family).to eq(family)
expect(membership.role).to eq('member')
end
@ -47,7 +47,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
it 'sets appropriate error message' do
@ -68,7 +68,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
end
@ -80,7 +80,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
end
@ -93,7 +93,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
end
@ -108,7 +108,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
end
end

View file

@ -16,7 +16,7 @@ RSpec.describe Families::Create do
it 'creates owner membership' do
service.call
membership = user.family_membership
membership = user.reload.family_membership
expect(membership.role).to eq('owner')
expect(membership.family).to eq(service.family)
end
@ -38,7 +38,7 @@ RSpec.describe Families::Create do
end
it 'does not create a membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
it 'sets appropriate error message' do
@ -65,7 +65,7 @@ RSpec.describe Families::Create do
end
it 'does not create a membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
end
it 'sets appropriate error message' do

View file

@ -12,7 +12,7 @@ RSpec.describe Families::Invite do
describe '#call' do
context 'when invitation is valid' do
it 'creates an invitation' do
expect { service.call }.to change(FamilyInvitation, :count).by(1)
expect { service.call }.to change(Family::Invitation, :count).by(1)
invitation = owner.sent_family_invitations.last
@ -51,7 +51,7 @@ RSpec.describe Families::Invite do
end
it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count)
expect { service.call }.not_to change(Family::Invitation, :count)
end
end
@ -66,7 +66,7 @@ RSpec.describe Families::Invite do
end
it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count)
expect { service.call }.not_to change(Family::Invitation, :count)
end
end
@ -83,7 +83,7 @@ RSpec.describe Families::Invite do
end
it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count)
expect { service.call }.not_to change(Family::Invitation, :count)
end
end
@ -97,7 +97,7 @@ RSpec.describe Families::Invite do
end
it 'does not create another invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count)
expect { service.call }.not_to change(Family::Invitation, :count)
end
end

View file

@ -17,7 +17,7 @@ RSpec.describe Families::Memberships::Destroy do
it 'removes the membership' do
result = service.call
expect(result).to be_truthy, "Expected service to succeed but got error: #{service.error_message}"
expect(FamilyMembership.count).to eq(1) # Only owner should remain
expect(Family::Membership.count).to eq(1) # Only owner should remain
expect(member.reload.family_membership).to be_nil
end
@ -47,7 +47,7 @@ RSpec.describe Families::Memberships::Destroy do
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
it 'prevents owner from leaving' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
expect(user.reload.family_membership).to be_present
end
@ -75,7 +75,7 @@ RSpec.describe Families::Memberships::Destroy do
end
it 'does not remove membership' do
expect { service.call }.not_to change(FamilyMembership, :count)
expect { service.call }.not_to change(Family::Membership, :count)
expect(user.reload.family_membership).to be_present
end
end