mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Complete phase 3
This commit is contained in:
parent
40fff59ec6
commit
cc5da3e7e2
9 changed files with 1410 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
89
app/controllers/families_controller.rb
Normal file
89
app/controllers/families_controller.rb
Normal 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
|
||||
79
app/controllers/family_invitations_controller.rb
Normal file
79
app/controllers/family_invitations_controller.rb
Normal 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
|
||||
42
app/controllers/family_memberships_controller.rb
Normal file
42
app/controllers/family_memberships_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
312
spec/requests/families_spec.rb
Normal file
312
spec/requests/families_spec.rb
Normal 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
|
||||
310
spec/requests/family_invitations_spec.rb
Normal file
310
spec/requests/family_invitations_spec.rb
Normal 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
|
||||
240
spec/requests/family_memberships_spec.rb
Normal file
240
spec/requests/family_memberships_spec.rb
Normal 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
|
||||
317
spec/requests/family_workflows_spec.rb
Normal file
317
spec/requests/family_workflows_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue