Fix some tests

This commit is contained in:
Eugene Burmakin 2025-09-27 14:26:08 +02:00
parent f0f0f20200
commit f817e3513c
4 changed files with 110 additions and 33 deletions

View file

@ -49,6 +49,7 @@ class FamilyInvitationsController < ApplicationController
)
if service.call
current_user.reload
redirect_to family_path(current_user.family), notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'

View file

@ -4,7 +4,7 @@ module Families
class Invite
include ActiveModel::Validations
attr_reader :family, :email, :invited_by, :invitation
attr_reader :family, :email, :invited_by, :invitation, :errors
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
@ -12,6 +12,7 @@ module Families
@family = family
@email = email.downcase.strip
@invited_by = invited_by
@errors = {}
end
def call
@ -25,21 +26,38 @@ module Families
end
true
rescue ActiveRecord::RecordInvalid
rescue ActiveRecord::RecordInvalid => e
@errors[:base] = e.message
false
end
def error_message
return errors.values.first if errors.any?
return validation_error_message unless valid?
'Failed to send invitation'
end
private
def validation_error_message
errors.full_messages.first || 'Invalid invitation data'
end
def invite_sendable?
return false unless invited_by.family_owner?
return false if family.members.count >= Family::MAX_MEMBERS
return false if user_already_in_family?
return false if pending_invitation_exists?
return add_error_and_false(:invited_by, 'You must be a family owner to send invitations') unless invited_by.family_owner?
return add_error_and_false(:family, 'Family is full') if family.members.count >= Family::MAX_MEMBERS
return add_error_and_false(:email, 'User is already in a family') if user_already_in_family?
return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists?
true
end
def add_error_and_false(attribute, message)
@errors[attribute] = message
false
end
def user_already_in_family?
User.joins(:family_membership)
.where(email: email)
@ -71,4 +89,4 @@ module Families
)
end
end
end
end

View file

@ -0,0 +1,57 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">
<%= t('family_invitations.index.title', default: 'Family Invitations') %>
</h1>
<%= link_to family_path(@family),
class: "bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" do %>
<%= t('family_invitations.index.back_to_family', default: 'Back to Family') %>
<% end %>
</div>
<% if @pending_invitations.any? %>
<div class="space-y-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900"><%= invitation.email %></div>
<div class="text-sm text-gray-500">
<%= t('family_invitations.index.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%B %d, %Y') %>
</div>
<div class="text-xs text-gray-400">
<%= t('family_invitations.index.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %>
</div>
</div>
<div class="flex space-x-2">
<%= link_to public_invitation_path(invitation.token),
class: "text-blue-600 hover:text-blue-800 text-sm font-medium" do %>
<%= t('family_invitations.index.view_invitation', default: 'View') %>
<% end %>
<% if policy(@family).manage_invitations? %>
<%= link_to family_invitation_path(@family, invitation),
method: :delete,
confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'),
class: "text-red-600 hover:text-red-800 text-sm font-medium" do %>
<%= t('family_invitations.index.cancel', default: 'Cancel') %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-8">
<p class="text-gray-500 text-lg">
<%= t('family_invitations.index.no_invitations', default: 'No pending invitations') %>
</p>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -120,18 +120,19 @@ RSpec.describe 'Family Invitations', type: :request do
post "/families/#{family.id}/invitations", params: duplicate_params
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Failed to send invitation')
expect(response.body).to include('Invitation already sent to this email')
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
it 'redirects due to authorization failure' do
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to have_http_status(:forbidden)
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
@ -162,27 +163,28 @@ RSpec.describe 'Family Invitations', type: :request do
describe 'POST /families/:family_id/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 "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
invitation.reload
expect(invitation.status).to eq('accepted')
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
@ -196,41 +198,39 @@ RSpec.describe 'Family Invitations', type: :request do
it 'does not accept the invitation' do
expect do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('You must leave your current family')
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
invitation.update!(expires_at: 1.day.ago)
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('expired')
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 "/families/#{family.id}/invitations/#{invitation.id}/accept"
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(new_user_session_path)
end
end
@ -240,14 +240,14 @@ RSpec.describe 'Family Invitations', type: :request do
before { sign_in user }
it 'cancels the invitation' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
delete "/families/#{family.id}/invitations/#{invitation.token}"
invitation.reload
expect(invitation.status).to eq('cancelled')
expect(response).to redirect_to(family_path(family))
end
it 'redirects with success message' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
delete "/families/#{family.id}/invitations/#{invitation.token}"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Invitation cancelled')
@ -256,9 +256,10 @@ RSpec.describe 'Family Invitations', type: :request do
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
expect(response).to have_http_status(:forbidden)
it 'redirects due to authorization failure' do
delete "/families/#{family.id}/invitations/#{invitation.token}"
expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized')
end
end
@ -268,7 +269,7 @@ RSpec.describe 'Family Invitations', type: :request do
before { sign_in outsider }
it 'redirects to families index' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
delete "/families/#{family.id}/invitations/#{invitation.token}"
expect(response).to redirect_to(families_path)
end
end
@ -277,7 +278,7 @@ RSpec.describe 'Family Invitations', type: :request do
before { sign_out user }
it 'redirects to login' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
delete "/families/#{family.id}/invitations/#{invitation.token}"
expect(response).to redirect_to(new_user_session_path)
end
end
@ -304,7 +305,7 @@ RSpec.describe 'Family Invitations', type: :request do
# 3. Invitee accepts invitation
sign_in invitee
post "/families/#{family.id}/invitations/#{created_invitation.id}/accept"
post "/families/#{family.id}/invitations/#{created_invitation.token}/accept"
expect(response).to redirect_to(family_path(family))
# 4. Verify invitee is now in family