Complete phase 3

This commit is contained in:
Eugene Burmakin 2025-09-27 13:23:33 +02:00
parent 40fff59ec6
commit cc5da3e7e2
9 changed files with 1410 additions and 5 deletions

View file

@ -1641,11 +1641,11 @@ end
5. ✅ Write comprehensive model tests
### Phase 2: Core Business Logic (Week 2)
1. Implement all service classes
2. Add invitation token generation and expiry logic
3. Create email templates and mailer
4. Write service tests
5. Add basic Pundit policies
1. Implement all service classes
2. Add invitation token generation and expiry logic
3. Create email templates and mailer
4. Write service tests
5. Add basic Pundit policies
### Phase 3: Controllers and Routes (Week 3)
1. Implement all controller classes

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
class FamiliesController < ApplicationController
before_action :authenticate_user!
before_action :set_family, only: %i[show edit update destroy leave]
def index
redirect_to family_path(current_user.family) if current_user.in_family?
end
def show
authorize @family
@members = @family.members.includes(:family_membership)
@pending_invitations = @family.family_invitations.active
end
def new
redirect_to family_path(current_user.family) if current_user.in_family?
@family = Family.new
end
def create
service = Families::Create.new(
user: current_user,
name: family_params[:name]
)
if service.call
redirect_to family_path(service.family), notice: 'Family created successfully!'
else
@family = Family.new(family_params)
service.errors.each do |attribute, message|
@family.errors.add(attribute, message)
end
render :new, status: :unprocessable_entity
end
end
def edit
authorize @family
end
def update
authorize @family
if @family.update(family_params)
redirect_to family_path(@family), notice: 'Family updated successfully!'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @family
if @family.members.count > 1
redirect_to family_path(@family), alert: 'Cannot delete family with members. Remove all members first.'
else
@family.destroy
redirect_to families_path, notice: 'Family deleted successfully!'
end
end
def leave
authorize @family, :leave?
service = Families::Leave.new(user: current_user)
if service.call
redirect_to families_path, notice: 'You have left the family'
else
redirect_to family_path(@family), alert: service.error_message || 'Cannot leave family.'
end
end
private
def set_family
@family = current_user.family
redirect_to families_path unless @family
end
def family_params
params.require(:family).permit(:name)
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
class FamilyInvitationsController < ApplicationController
before_action :authenticate_user!, except: %i[show accept]
before_action :set_family, except: %i[show accept]
before_action :set_invitation, only: %i[show accept destroy]
def index
authorize @family, :show?
@pending_invitations = @family.family_invitations.active
end
def show
# Public endpoint for invitation acceptance
if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.'
return
end
return if @invitation.pending?
redirect_to root_path, alert: 'This invitation is no longer valid.'
nil
end
def create
authorize @family, :invite?
service = Families::Invite.new(
family: @family,
email: invitation_params[:email],
invited_by: current_user
)
if service.call
redirect_to family_path(@family), notice: 'Invitation sent successfully!'
else
redirect_to family_path(@family), alert: service.error_message || 'Failed to send invitation'
end
end
def accept
authenticate_user!
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
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'
end
end
def destroy
authorize @family, :manage_invitations?
@invitation.update!(status: :cancelled)
redirect_to family_path(@family), notice: 'Invitation cancelled'
end
private
def set_family
@family = current_user.family
redirect_to families_path unless @family
end
def set_invitation
@invitation = FamilyInvitation.find_by!(token: params[:id])
end
def invitation_params
params.require(:family_invitation).permit(:email)
end
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
class FamilyMembershipsController < ApplicationController
before_action :authenticate_user!
before_action :set_family
before_action :set_membership, only: %i[show destroy]
def index
authorize @family, :show?
@members = @family.members.includes(:family_membership)
end
def show
authorize @membership, :show?
end
def destroy
authorize @membership
if @membership.owner? && @family.members.count > 1
redirect_to family_path(@family),
alert: 'Cannot remove family owner while other members exist. Transfer ownership first.'
else
member_email = @membership.user.email
@membership.destroy!
redirect_to family_path(@family), notice: "#{member_email} has been removed from the family"
end
end
private
def set_family
@family = current_user.family
redirect_to families_path, alert: 'Family not found' and return unless @family
end
def set_membership
@membership = @family.family_memberships.find(params[:id])
end
end

View file

@ -56,6 +56,22 @@ Rails.application.routes.draw do
resources :places, only: %i[index destroy]
resources :exports, only: %i[index create destroy]
resources :trips
# Family management routes
resources :families do
member do
delete :leave
end
resources :invitations, except: %i[edit update], controller: 'family_invitations' do
member do
post :accept
end
end
resources :members, only: %i[index show destroy], controller: 'family_memberships'
end
# Public family invitation acceptance (no auth required)
get 'invitations/:id', to: 'family_invitations#show', as: :public_invitation
resources :points, only: %i[index] do
collection do
delete :bulk_destroy

View file

@ -0,0 +1,312 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Families', type: :request do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
before { sign_in user }
describe 'GET /families' do
context 'when user is not in a family' do
let(:user_without_family) { create(:user) }
before { sign_in user_without_family }
it 'renders the index page' do
get '/families'
expect(response).to have_http_status(:ok)
end
end
context 'when user is in a family' do
it 'redirects to family show page' do
get '/families'
expect(response).to redirect_to(family_path(family))
end
end
end
describe 'GET /families/:id' do
it 'shows the family page' do
get "/families/#{family.id}"
expect(response).to have_http_status(:ok)
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
get "/families/#{family.id}"
expect(response).to redirect_to(families_path)
end
end
end
describe 'GET /families/new' do
context 'when user is not in a family' do
let(:user_without_family) { create(:user) }
before { sign_in user_without_family }
it 'renders the new family form' do
get '/families/new'
expect(response).to have_http_status(:ok)
end
end
context 'when user is already in a family' do
it 'redirects to family show page' do
get '/families/new'
expect(response).to redirect_to(family_path(family))
end
end
end
describe 'POST /families' do
let(:user_without_family) { create(:user) }
before { sign_in user_without_family }
context 'with valid attributes' do
let(:valid_attributes) { { family: { name: 'Test Family' } } }
it 'creates a new family' do
expect do
post '/families', params: valid_attributes
end.to change(Family, :count).by(1)
end
it 'creates a family membership for the user' do
expect do
post '/families', params: valid_attributes
end.to change(FamilyMembership, :count).by(1)
end
it 'redirects to the new family with success message' do
post '/families', params: valid_attributes
created_family = Family.last
expect(response).to redirect_to(family_path(created_family))
follow_redirect!
expect(response.body).to include('Family created successfully!')
end
end
context 'with invalid attributes' do
let(:invalid_attributes) { { family: { name: '' } } }
it 'does not create a family' do
expect do
post '/families', params: invalid_attributes
end.not_to change(Family, :count)
end
it 'renders the new template with errors' do
post '/families', params: invalid_attributes
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'GET /families/:id/edit' do
it 'shows the edit form' do
get "/families/#{family.id}/edit"
expect(response).to have_http_status(:ok)
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
get "/families/#{family.id}/edit"
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'PATCH /families/:id' do
let(:new_attributes) { { family: { name: 'Updated Family Name' } } }
context 'with valid attributes' do
it 'updates the family' do
patch "/families/#{family.id}", params: new_attributes
family.reload
expect(family.name).to eq('Updated Family Name')
expect(response).to redirect_to(family_path(family))
end
end
context 'with invalid attributes' do
let(:invalid_attributes) { { family: { name: '' } } }
it 'does not update the family' do
original_name = family.name
patch "/families/#{family.id}", params: invalid_attributes
family.reload
expect(family.name).to eq(original_name)
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
patch "/families/#{family.id}", params: new_attributes
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'DELETE /families/:id' do
context 'when family has only one member' do
it 'deletes the family' do
expect do
delete "/families/#{family.id}"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path)
end
end
context 'when family has multiple members' do
before do
create(:family_membership, user: other_user, family: family, role: :member)
end
it 'does not delete the family' do
expect do
delete "/families/#{family.id}"
end.not_to change(Family, :count)
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Cannot delete family with members')
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
delete "/families/#{family.id}"
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'DELETE /families/:id/leave' do
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'allows user to leave the family' do
expect do
delete "/families/#{family.id}/leave"
end.to change { user.reload.family }.from(family).to(nil)
expect(response).to redirect_to(families_path)
end
end
context 'when user is the owner with other members' do
before do
create(:family_membership, user: other_user, family: family, role: :member)
end
it 'prevents leaving and shows error message' do
expect do
delete "/families/#{family.id}/leave"
end.not_to(change { user.reload.family })
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('cannot leave')
end
end
context 'when user is the only owner' do
it 'allows leaving and deletes the family' do
expect do
delete "/families/#{family.id}/leave"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path)
end
end
end
describe 'authorization for outsiders' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'denies access to show when user is not in family' do
get "/families/#{family.id}"
expect(response).to redirect_to(families_path)
end
it 'denies access to edit when user is not in family' do
get "/families/#{family.id}/edit"
expect(response).to have_http_status(:forbidden)
end
it 'denies access to update when user is not in family' do
patch "/families/#{family.id}", params: { family: { name: 'Hacked' } }
expect(response).to have_http_status(:forbidden)
end
it 'denies access to destroy when user is not in family' do
delete "/families/#{family.id}"
expect(response).to have_http_status(:forbidden)
end
it 'denies access to leave when user is not in family' do
delete "/families/#{family.id}/leave"
expect(response).to have_http_status(:forbidden)
end
end
describe 'authentication required' do
before { sign_out user }
it 'redirects to login for index' do
get '/families'
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for show' do
get "/families/#{family.id}"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for new' do
get '/families/new'
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for create' do
post '/families', params: { family: { name: 'Test' } }
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for edit' do
get "/families/#{family.id}/edit"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for update' do
patch "/families/#{family.id}", params: { family: { name: 'Test' } }
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for destroy' do
delete "/families/#{family.id}"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for leave' do
delete "/families/#{family.id}/leave"
expect(response).to redirect_to(new_user_session_path)
end
end
end

View file

@ -0,0 +1,310 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family Invitations', type: :request do
let(:user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
let(:invitation) { create(:family_invitation, family: family, invited_by: user) }
describe 'GET /families/:family_id/invitations' do
before { sign_in user }
it 'shows pending invitations' do
invitation # create the invitation
get "/families/#{family.id}/invitations"
expect(response).to have_http_status(:ok)
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
get "/families/#{family.id}/invitations"
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
get "/families/#{family.id}/invitations"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'GET /invitations/:token (public invitation view)' do
context 'when invitation is valid and pending' do
it 'shows the invitation without authentication' do
get "/invitations/#{invitation.token}"
expect(response).to have_http_status(:ok)
end
end
context 'when invitation is expired' do
before { invitation.update!(expires_at: 1.day.ago) }
it 'redirects with error message' do
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation has expired')
end
end
context 'when invitation is not pending' do
before { invitation.update!(status: :accepted) }
it 'redirects with error message' do
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation is no longer valid')
end
end
context 'when invitation does not exist' do
it 'returns not found' do
get '/invitations/invalid-token'
expect(response).to have_http_status(:not_found)
end
end
end
describe 'POST /families/:family_id/invitations' do
before { sign_in user }
context 'with valid email' do
let(:valid_params) do
{ family_invitation: { email: 'newuser@example.com' } }
end
it 'creates a new invitation' do
expect do
post "/families/#{family.id}/invitations", params: valid_params
end.to change(FamilyInvitation, :count).by(1)
end
it 'redirects with success message' do
post "/families/#{family.id}/invitations", params: valid_params
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Invitation sent successfully!')
end
end
context 'with duplicate email' do
let(:duplicate_params) do
{ family_invitation: { email: invitation.email } }
end
it 'does not create a duplicate invitation' do
invitation # create the existing invitation
expect do
post "/families/#{family.id}/invitations", params: duplicate_params
end.not_to change(FamilyInvitation, :count)
end
it 'redirects with error message' do
invitation # create the existing invitation
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')
end
end
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'returns forbidden' do
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: 'test@example.com' }
}
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'POST /families/:family_id/invitations/:id/accept' do
let(:invitee) { create(:user) }
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"
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"
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')
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 "/families/#{family.id}/invitations/#{invitation.id}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('You must leave your current family')
end
end
context 'when invitation is expired' do
before do
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"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('expired')
end
end
context 'when not authenticated' do
it 'redirects to login' do
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /families/:family_id/invitations/:id' do
before { sign_in user }
it 'cancels the invitation' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
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}"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Invitation cancelled')
end
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)
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
delete "/families/#{family.id}/invitations/#{invitation.id}"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'invitation workflow integration' do
let(:invitee) { create(:user) }
it 'completes full invitation acceptance workflow' do
# 1. Owner creates invitation
sign_in user
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: invitee.email }
}
expect(response).to redirect_to(family_path(family))
created_invitation = FamilyInvitation.last
expect(created_invitation.email).to eq(invitee.email)
# 2. Invitee views public invitation page
sign_out user
get "/invitations/#{created_invitation.token}"
expect(response).to have_http_status(:ok)
# 3. Invitee accepts invitation
sign_in invitee
post "/families/#{family.id}/invitations/#{created_invitation.id}/accept"
expect(response).to redirect_to(family_path(family))
# 4. Verify invitee is now in family
expect(invitee.reload.family).to eq(family)
expect(created_invitation.reload.status).to eq('accepted')
end
end
end

View file

@ -0,0 +1,240 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family Memberships', type: :request do
let(:user) { create(:user) }
let(:family) { create(:family, creator: user) }
let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) }
let(:member_user) { create(:user) }
let!(:member_membership) { create(:family_membership, user: member_user, family: family, role: :member) }
before { sign_in user }
describe 'GET /families/:family_id/members' do
it 'shows all family members' do
get "/families/#{family.id}/members"
expect(response).to have_http_status(:ok)
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
get "/families/#{family.id}/members"
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
get "/families/#{family.id}/members"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'GET /families/:family_id/members/:id' do
it 'shows a specific membership' do
get "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to have_http_status(:ok)
end
context 'when membership does not belong to the family' do
let(:other_family) { create(:family) }
let(:other_membership) { create(:family_membership, family: other_family) }
it 'returns not found' do
get "/families/#{family.id}/members/#{other_membership.id}"
expect(response).to have_http_status(:not_found)
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
get "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
get "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /families/:family_id/members/:id' do
context 'when removing a regular member' do
it 'removes the member from the family' do
expect do
delete "/families/#{family.id}/members/#{member_membership.id}"
end.to change(FamilyMembership, :count).by(-1)
end
it 'redirects with success message' do
member_email = member_user.email
delete "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include("#{member_email} has been removed from the family")
end
it 'removes the user from the family' do
delete "/families/#{family.id}/members/#{member_membership.id}"
expect(member_user.reload.family).to be_nil
end
end
context 'when trying to remove the owner while other members exist' do
it 'does not remove the owner' do
expect do
delete "/families/#{family.id}/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count)
end
it 'redirects with error message' do
delete "/families/#{family.id}/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Cannot remove family owner while other members exist')
end
end
context 'when owner is the only member' do
before { member_membership.destroy! }
it 'allows removing the owner' do
expect do
delete "/families/#{family.id}/members/#{owner_membership.id}"
end.to change(FamilyMembership, :count).by(-1)
end
it 'redirects with success message' do
user_email = user.email
delete "/families/#{family.id}/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include("#{user_email} has been removed from the family")
end
end
context 'when membership does not belong to the family' do
let(:other_family) { create(:family) }
let(:other_membership) { create(:family_membership, family: other_family) }
it 'returns not found' do
delete "/families/#{family.id}/members/#{other_membership.id}"
expect(response).to have_http_status(:not_found)
end
end
context 'when user is not in the family' do
let(:outsider) { create(:user) }
before { sign_in outsider }
it 'redirects to families index' do
delete "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to redirect_to(families_path)
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
delete "/families/#{family.id}/members/#{member_membership.id}"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'authorization for different member roles' do
context 'when member tries to remove another member' do
before { sign_in member_user }
it 'returns forbidden' do
delete "/families/#{family.id}/members/#{owner_membership.id}"
expect(response).to have_http_status(:forbidden)
end
end
context 'when member views another member' do
before { sign_in member_user }
it 'allows viewing membership' do
get "/families/#{family.id}/members/#{owner_membership.id}"
expect(response).to have_http_status(:ok)
end
end
context 'when member views members list' do
before { sign_in member_user }
it 'allows viewing members list' do
get "/families/#{family.id}/members"
expect(response).to have_http_status(:ok)
end
end
end
describe 'member removal workflow' do
it 'removes member and updates family associations' do
# Verify initial state
expect(family.members).to include(user, member_user)
expect(member_user.family).to eq(family)
# Remove member
delete "/families/#{family.id}/members/#{member_membership.id}"
# Verify removal
expect(response).to redirect_to(family_path(family))
expect(family.reload.members).to include(user)
expect(family.members).not_to include(member_user)
expect(member_user.reload.family).to be_nil
end
it 'prevents removing owner when family has members' do
# Verify initial state
expect(family.members.count).to eq(2)
expect(user.family_owner?).to be true
# Try to remove owner
delete "/families/#{family.id}/members/#{owner_membership.id}"
# Verify prevention
expect(response).to redirect_to(family_path(family))
expect(family.reload.members).to include(user, member_user)
expect(user.reload.family).to eq(family)
end
it 'allows removing owner when they are the only member' do
# Remove other member first
member_membership.destroy!
# Verify only owner remains
expect(family.reload.members.count).to eq(1)
expect(family.members).to include(user)
# Remove owner
expect do
delete "/families/#{family.id}/members/#{owner_membership.id}"
end.to change(FamilyMembership, :count).by(-1)
expect(response).to redirect_to(family_path(family))
expect(user.reload.family).to be_nil
end
end
end

View file

@ -0,0 +1,317 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Family Workflows', type: :request do
let(:user1) { create(:user, email: 'alice@example.com') }
let(:user2) { create(:user, email: 'bob@example.com') }
let(:user3) { create(:user, email: 'charlie@example.com') }
describe 'Complete family creation and management workflow' do
it 'allows creating a family, inviting members, and managing the family' do
# Step 1: User1 creates a family
sign_in user1
get '/families'
expect(response).to have_http_status(:ok)
get '/families/new'
expect(response).to have_http_status(:ok)
post '/families', params: { family: { name: 'The Smith Family' } }
expect(response).to redirect_to(family_path(Family.last))
family = Family.last
expect(family.name).to eq('The Smith Family')
expect(family.creator).to eq(user1)
expect(user1.reload.family).to eq(family)
expect(user1.family_owner?).to be true
# Step 2: User1 invites User2
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: user2.email }
}
expect(response).to redirect_to(family_path(family))
invitation = FamilyInvitation.last
expect(invitation.email).to eq(user2.email)
expect(invitation.family).to eq(family)
expect(invitation.pending?).to be true
# Step 3: User2 views and accepts invitation
sign_out user1
# Public invitation view (no auth required)
get "/invitations/#{invitation.token}"
expect(response).to have_http_status(:ok)
# User2 accepts invitation
sign_in user2
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
expect(response).to redirect_to(family_path(family))
expect(user2.reload.family).to eq(family)
expect(user2.family_owner?).to be false
expect(invitation.reload.accepted?).to be true
# Step 4: User1 invites User3
sign_in user1
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: user3.email }
}
invitation2 = FamilyInvitation.last
expect(invitation2.email).to eq(user3.email)
# Step 5: User3 accepts invitation
sign_in user3
post "/families/#{family.id}/invitations/#{invitation2.id}/accept"
expect(user3.reload.family).to eq(family)
expect(family.reload.members.count).to eq(3)
# Step 6: Family owner views and manages members
sign_in user1
get "/families/#{family.id}/members"
expect(response).to have_http_status(:ok)
get "/families/#{family.id}/members/#{user2.family_membership.id}"
expect(response).to have_http_status(:ok)
# Step 7: Owner removes a member
delete "/families/#{family.id}/members/#{user2.family_membership.id}"
expect(response).to redirect_to(family_path(family))
expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(2)
expect(family.members).to include(user1, user3)
expect(family.members).not_to include(user2)
end
end
describe 'Family invitation expiration workflow' do
it 'handles expired invitations correctly' do
# User1 creates family and invitation
sign_in user1
post '/families', params: { family: { name: 'Test Family' } }
family = Family.last
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: user2.email }
}
invitation = FamilyInvitation.last
# Expire the invitation
invitation.update!(expires_at: 1.day.ago)
# User2 tries to view expired invitation
sign_out user1
get "/invitations/#{invitation.token}"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('This invitation has expired')
# User2 tries to accept expired invitation
sign_in user2
post "/families/#{family.id}/invitations/#{invitation.id}/accept"
expect(response).to redirect_to(root_path)
expect(user2.reload.family).to be_nil
expect(invitation.reload.pending?).to be true
end
end
describe 'Multiple family membership prevention workflow' do
it 'prevents users from joining multiple families' do
# User1 creates first family
sign_in user1
post '/families', params: { family: { name: 'Family 1' } }
family1 = Family.last
# User2 creates second family
sign_in user2
post '/families', params: { family: { name: 'Family 2' } }
family2 = Family.last
# User1 invites User3 to Family 1
sign_in user1
post "/families/#{family1.id}/invitations", params: {
family_invitation: { email: user3.email }
}
invitation1 = FamilyInvitation.last
# User2 invites User3 to Family 2
sign_in user2
post "/families/#{family2.id}/invitations", params: {
family_invitation: { email: user3.email }
}
invitation2 = FamilyInvitation.last
# User3 accepts invitation to Family 1
sign_in user3
post "/families/#{family1.id}/invitations/#{invitation1.id}/accept"
expect(user3.reload.family).to eq(family1)
# User3 tries to accept invitation to Family 2
post "/families/#{family2.id}/invitations/#{invitation2.id}/accept"
expect(response).to redirect_to(root_path)
follow_redirect!
expect(response.body).to include('You must leave your current family')
expect(user3.reload.family).to eq(family1) # Still in first family
end
end
describe 'Family ownership transfer and leaving workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) }
it 'prevents owner from leaving when members exist' do
sign_in user1
# Owner tries to leave family with members
delete "/families/#{family.id}/leave"
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('cannot leave')
expect(user1.reload.family).to eq(family)
expect(user1.family_owner?).to be true
end
it 'allows owner to leave when they are the only member' do
sign_in user1
# Remove the member first
delete "/families/#{family.id}/members/#{member_membership.id}"
# Now owner can leave (which deletes the family)
expect do
delete "/families/#{family.id}/leave"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path)
expect(user1.reload.family).to be_nil
end
it 'allows members to leave freely' do
sign_in user2
delete "/families/#{family.id}/leave"
expect(response).to redirect_to(families_path)
expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(1)
expect(family.members).to include(user1)
expect(family.members).not_to include(user2)
end
end
describe 'Family deletion workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
it 'prevents family deletion when members exist' do
create(:family_membership, user: user2, family: family, role: :member)
sign_in user1
expect do
delete "/families/#{family.id}"
end.not_to change(Family, :count)
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('Cannot delete family with members')
end
it 'allows family deletion when owner is the only member' do
sign_in user1
expect do
delete "/families/#{family.id}"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path)
expect(user1.reload.family).to be_nil
end
end
describe 'Authorization workflow' do
let(:family) { create(:family, creator: user1) }
let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) }
let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) }
it 'enforces proper authorization for family management' do
# Member cannot invite others
sign_in user2
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: user3.email }
}
expect(response).to have_http_status(:forbidden)
# Member cannot remove other members
delete "/families/#{family.id}/members/#{owner_membership.id}"
expect(response).to have_http_status(:forbidden)
# Member cannot edit family
patch "/families/#{family.id}", params: { family: { name: 'Hacked Family' } }
expect(response).to have_http_status(:forbidden)
# Member cannot delete family
delete "/families/#{family.id}"
expect(response).to have_http_status(:forbidden)
# Outsider cannot access family
sign_in user3
get "/families/#{family.id}"
expect(response).to redirect_to(families_path)
get "/families/#{family.id}/members"
expect(response).to redirect_to(families_path)
end
end
describe 'Email invitation workflow' do
it 'handles invitation emails correctly' do
sign_in user1
post '/families', params: { family: { name: 'Test Family' } }
family = Family.last
# Mock email delivery
expect do
post "/families/#{family.id}/invitations", params: {
family_invitation: { email: 'newuser@example.com' }
}
end.to change(FamilyInvitation, :count).by(1)
invitation = FamilyInvitation.last
expect(invitation.email).to eq('newuser@example.com')
expect(invitation.token).to be_present
expect(invitation.expires_at).to be > Time.current
end
end
describe 'Navigation and redirect workflow' do
it 'handles proper redirects for family-related navigation' do
# User without family sees index
sign_in user1
get '/families'
expect(response).to have_http_status(:ok)
# User creates family
post '/families', params: { family: { name: 'Test Family' } }
family = Family.last
# User with family gets redirected from index to family page
get '/families'
expect(response).to redirect_to(family_path(family))
# User with family gets redirected from new family page
get '/families/new'
expect(response).to redirect_to(family_path(family))
end
end
end