From cc5da3e7e2a52ddc63ebe249676861b9abcdeee7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 27 Sep 2025 13:23:33 +0200 Subject: [PATCH] Complete phase 3 --- FAMILY_PLAN.md | 10 +- app/controllers/families_controller.rb | 89 +++++ .../family_invitations_controller.rb | 79 +++++ .../family_memberships_controller.rb | 42 +++ config/routes.rb | 16 + spec/requests/families_spec.rb | 312 +++++++++++++++++ spec/requests/family_invitations_spec.rb | 310 +++++++++++++++++ spec/requests/family_memberships_spec.rb | 240 +++++++++++++ spec/requests/family_workflows_spec.rb | 317 ++++++++++++++++++ 9 files changed, 1410 insertions(+), 5 deletions(-) create mode 100644 app/controllers/families_controller.rb create mode 100644 app/controllers/family_invitations_controller.rb create mode 100644 app/controllers/family_memberships_controller.rb create mode 100644 spec/requests/families_spec.rb create mode 100644 spec/requests/family_invitations_spec.rb create mode 100644 spec/requests/family_memberships_spec.rb create mode 100644 spec/requests/family_workflows_spec.rb diff --git a/FAMILY_PLAN.md b/FAMILY_PLAN.md index 6a6a0edb..641a3328 100644 --- a/FAMILY_PLAN.md +++ b/FAMILY_PLAN.md @@ -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 diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb new file mode 100644 index 00000000..55129d8e --- /dev/null +++ b/app/controllers/families_controller.rb @@ -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 diff --git a/app/controllers/family_invitations_controller.rb b/app/controllers/family_invitations_controller.rb new file mode 100644 index 00000000..4e663d2b --- /dev/null +++ b/app/controllers/family_invitations_controller.rb @@ -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 diff --git a/app/controllers/family_memberships_controller.rb b/app/controllers/family_memberships_controller.rb new file mode 100644 index 00000000..f23509c5 --- /dev/null +++ b/app/controllers/family_memberships_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 4424f062..300da2da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/families_spec.rb b/spec/requests/families_spec.rb new file mode 100644 index 00000000..d848ba27 --- /dev/null +++ b/spec/requests/families_spec.rb @@ -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 diff --git a/spec/requests/family_invitations_spec.rb b/spec/requests/family_invitations_spec.rb new file mode 100644 index 00000000..484987bd --- /dev/null +++ b/spec/requests/family_invitations_spec.rb @@ -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 diff --git a/spec/requests/family_memberships_spec.rb b/spec/requests/family_memberships_spec.rb new file mode 100644 index 00000000..ef0debc6 --- /dev/null +++ b/spec/requests/family_memberships_spec.rb @@ -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 diff --git a/spec/requests/family_workflows_spec.rb b/spec/requests/family_workflows_spec.rb new file mode 100644 index 00000000..211a960c --- /dev/null +++ b/spec/requests/family_workflows_spec.rb @@ -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