diff --git a/app/controllers/check_ins_controller.rb b/app/controllers/check_ins_controller.rb index 89343201d..6cd98b99a 100644 --- a/app/controllers/check_ins_controller.rb +++ b/app/controllers/check_ins_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class CheckInsController < ApplicationController + include RoleChoiceConcerns before_action :load_check_in_target before_action :store_referer_path, only: [:new] before_action :authenticate_member!, only: %i[new create confirm] @@ -17,7 +18,17 @@ def create if existing return redirect_to check_in_confirm_path if already_checked_in?(existing) - return redirect_to check_in_new_path, alert: mismatch_role_message(existing.role) if existing.role != role + + if existing.role != role + unless role_changeable?(existing) + return redirect_to check_in_new_path, alert: mismatch_role_message(existing.role) + end + unless role_permitted?(current_user, role) + return redirect_to check_in_new_path, alert: not_subscribed_role_message(role) + end + end + elsif !role_permitted?(current_user, role) + return redirect_to check_in_new_path, alert: not_subscribed_role_message(role) end unless @check_in_target.check_in_open? @@ -34,7 +45,7 @@ def create return redirect_to check_in_new_path, alert: "There are no #{role} spaces left." end - mark_attended(invitation) + mark_attended(invitation, role) redirect_to check_in_confirm_path rescue ActionController::ParameterMissing redirect_to check_in_new_path, alert: 'Please select a valid role (Student or Coach).' @@ -108,8 +119,8 @@ def find_or_create_invitation(role) end end - def mark_attended(invitation) - attrs = { attending: true, source: InvitationConcerns::SOURCE_CHECK_IN } + def mark_attended(invitation, role) + attrs = { role:, attending: true, source: InvitationConcerns::SOURCE_CHECK_IN } if @check_in_target.is_a?(Event) attrs[:verified] = true else diff --git a/app/controllers/concerns/role_choice_concerns.rb b/app/controllers/concerns/role_choice_concerns.rb new file mode 100644 index 000000000..e0ef737da --- /dev/null +++ b/app/controllers/concerns/role_choice_concerns.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Role choice for members subscribed as both student and coach (issue #3040). +# The member's choice wins and the role can change until they accept or join a +# waiting list; a cancelled member can choose again. Same semantics as +# WorkshopsController#find_or_create_invitation. +module RoleChoiceConcerns + extend ActiveSupport::Concern + + ROLES = %w[Student Coach].freeze + + private + + # The role the request asks for. Falls back to the invitation's role unless + # the member may choose: they must be subscribed to the role and the + # invitation must still be changeable. Callers write it in the same update + # that completes the action, so a refused request changes nothing. + def chosen_role(invitation) + role = params[:role] + return invitation.role unless role.in?(ROLES) + return invitation.role unless role_changeable?(invitation) + return invitation.role unless role_permitted?(invitation.member, role) + + role + end + + # A member may only take a role they are subscribed to. Members subscribed as + # both student and coach can therefore choose, and the choice is open on the + # server for anyone the views already offer it to. + def role_permitted?(member, role) + role == 'Student' ? member.student? : member.coach? + end + + def not_subscribed_role_message(role) + "You are not subscribed as a #{role}." + end + + def role_changeable?(invitation) + return false if invitation.attending? + return false if invitation.respond_to?(:waiting_list) && invitation.waiting_list.present? + + true + end + + def role_choice_available?(invitation) + member = invitation.member + member.student? && member.coach? && role_changeable?(invitation) + end +end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 335b89c7f..e07818ef8 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -1,4 +1,5 @@ class InvitationsController < ApplicationController + include RoleChoiceConcerns # The invitation token in the URL is the authenticator for these actions; # CSRF is redundant and fails when browsers withhold the session cookie # (e.g. Safari/WebKit ITP on cross-site navigation). Same rationale as @@ -26,6 +27,7 @@ def show @event = EventPresenter.new(@invitation.event) @host_address = AddressPresenter.new(@event.venue.address) if @event.venue.present? @member = @invitation.member + @role_choice = role_choice_available?(@invitation) && !@member.already_attending(@event) end def attend @@ -36,8 +38,10 @@ def attend notice: t('messages.invitations.rsvps_closed') end - if @invitation.student_spaces? || @invitation.coach_spaces? - @invitation.update!(attending: true) + role = chosen_role(@invitation) + + if (role == 'Student' && event.student_spaces?) || (role == 'Coach' && event.coach_spaces?) + @invitation.update!(attending: true, role:) MemberActivityRecorder.record(actor: @invitation.member, key: 'event_invitation.rsvp', trackable: @invitation) diff --git a/app/controllers/waiting_lists_controller.rb b/app/controllers/waiting_lists_controller.rb index adb980f36..a5115cf9b 100644 --- a/app/controllers/waiting_lists_controller.rb +++ b/app/controllers/waiting_lists_controller.rb @@ -1,5 +1,6 @@ class WaitingListsController < ApplicationController include WorkshopInvitationConcerns + include RoleChoiceConcerns # The invitation token in the URL is the authenticator for these actions; # CSRF is redundant and fails when browsers withhold the session cookie @@ -7,26 +8,18 @@ class WaitingListsController < ApplicationController # FeedbackController#submit (PR #2641, Rollbar #535). skip_forgery_protection only: %i[create destroy] - def create # rubocop:disable Metrics/MethodLength + def create # Joins and leaves stop at the earlier of the RSVP close time and the # 3.5-hour freeze, so the waitlist handed to security stays stable. return back_with_message(t('messages.waiting_list.closed')) if waitlist_closed? - @invitation.assign_attributes(invitation_params) + @invitation.assign_attributes(invitation_params.merge(role: chosen_role(@invitation))) return back_with_message(@invitation.errors.full_messages) unless @invitation.valid?(:waitinglist) - @invitation.save && WaitingList.add(@invitation, auto_rsvp) - MemberActivityRecorder.record(actor: @invitation.member, key: 'waiting_list.joined', - trackable: @invitation) + join_waiting_list - message = if auto_rsvp - 'You have been added to the waiting list' - else - 'We will send you an email if any spots become available' - end - - back_with_message(message) + back_with_message(waiting_list_message) end def destroy @@ -47,6 +40,24 @@ def destroy private + def join_waiting_list + @invitation.save! + WaitingList.add(@invitation, auto_rsvp) + MemberActivityRecorder.record( + actor: @invitation.member, + key: 'waiting_list.joined', + trackable: @invitation + ) + end + + def waiting_list_message + if auto_rsvp + 'You have been added to the waiting list' + else + 'We will send you an email if any spots become available' + end + end + def waitlist_closed? !@invitation.workshop.waitlist_open? end diff --git a/app/controllers/workshop_invitation_controller.rb b/app/controllers/workshop_invitation_controller.rb index 7008fdfc1..0577eee57 100644 --- a/app/controllers/workshop_invitation_controller.rb +++ b/app/controllers/workshop_invitation_controller.rb @@ -1,6 +1,7 @@ class WorkshopInvitationController < ApplicationController include WorkshopInvitationConcerns include WaitlistPromotionConcerns + include RoleChoiceConcerns # NOTE: This controller handles workshop invitations (WorkshopInvitation model). # It provides accept/reject RSVP actions for workshop attendees via token-based links. @@ -17,6 +18,7 @@ def show @tutorial_titles = Tutorial.all_titles @workshop = WorkshopPresenter.decorate(@invitation.workshop) + @role_choice = role_choice_available?(@invitation) render plain: @workshop.attendees_csv if request.format.csv? end @@ -42,10 +44,12 @@ def accept return back_with_message(t('messages.already_invited')) if attending_or_waitlisted?(workshop, user) + role = chosen_role(@invitation) + @workshop = WorkshopPresenter.decorate(@invitation.workshop) - return back_with_message(t('messages.no_available_seats')) unless available_spaces?(@workshop, @invitation) + return back_with_message(t('messages.no_available_seats')) unless available_spaces?(@workshop, role) - if @invitation.update(invitation_params.merge!(attending: true, rsvp_time: Time.zone.now)) + if @invitation.update(invitation_params.merge!(attending: true, rsvp_time: Time.zone.now, role:)) MemberActivityRecorder.record(actor: @invitation.member, key: 'workshop_invitation.rsvp', trackable: @invitation) @workshop.send_attending_email(@invitation) @@ -92,9 +96,9 @@ def invitation_params end end - def available_spaces?(workshop, invitation) - (invitation.role.eql?('Student') && workshop.event_student_spaces?) || - (invitation.role.eql?('Coach') && workshop.event_coach_spaces?) + def available_spaces?(workshop, role) + (role == 'Student' && workshop.event_student_spaces?) || + (role == 'Coach' && workshop.event_coach_spaces?) end # Inline from InvitationControllerConcerns diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index c5115214f..7a9b6d59b 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -1,4 +1,6 @@ class WorkshopsController < ApplicationController + include RoleChoiceConcerns + before_action :authenticate_member!, except: [:show] before_action :set_workshop, only: %i[show rsvp] @@ -37,6 +39,10 @@ def rsvp return redirect_back fallback_location: root_path, notice: t('workshops.already_wish_to_attend') end + unless role_permitted?(current_user, role_params) + return redirect_back fallback_location: root_path, notice: not_subscribed_role_message(role_params) + end + @invitation = find_or_create_invitation(@workshop, current_user, role_params) end diff --git a/app/views/invitations/show.html.haml b/app/views/invitations/show.html.haml index e7727670d..c05ba0369 100644 --- a/app/views/invitations/show.html.haml +++ b/app/views/invitations/show.html.haml @@ -12,7 +12,17 @@ .row.mt-4 .col-12.col-md-9 - - if @invitation.for_student? + - if @role_choice && @event.tito_url.blank? + %p + You are subscribed as both a student and a coach. + %strong Choose how you would like to attend. + - if @event.event_student_spaces? + = link_to 'RSVP as a student', event_attend_path(role: 'Student'), class: 'btn btn-primary', method: :post + - if @event.event_coach_spaces? + = link_to 'RSVP as a coach', event_attend_path(role: 'Coach'), class: 'btn btn-primary', method: :post + - unless @event.event_student_spaces? || @event.event_coach_spaces? + %p Unfortunately there are no spots left. Send us an email at #{@event.email} to be added to the waiting list. + - elsif @invitation.for_student? - if !@invitation.attending - if @member.already_attending(@event) %p You have already RSVP'd to this event as a coach! Please cancel through the email confirmation you received if you wish to join us as a student instead. @@ -30,7 +40,7 @@ = link_to event_reject_path, class: 'btn btn-danger', method: :post, 'data-confirm' => "Are you sure you won't be able to attend the event? There may not be any spots available later on." do Cancel my spot - - if @invitation.for_coach? + - elsif @invitation.for_coach? - if !@invitation.attending - if @member.already_attending(@event) %p You have already RSVP'd to this event as a student! Please cancel through the email confirmation you received if you wish to sign up as a coach instead. diff --git a/app/views/workshop_invitation/_waiting_list.html.haml b/app/views/workshop_invitation/_waiting_list.html.haml index c98a41b04..e406eaa73 100644 --- a/app/views/workshop_invitation/_waiting_list.html.haml +++ b/app/views/workshop_invitation/_waiting_list.html.haml @@ -1,17 +1,18 @@ --# locals: (invitation:) +-# locals: (invitation:, role: nil, button_label: nil) +- role ||= invitation.role +- button_label ||= 'Join the waiting list' - if invitation.waiting_list.blank? %span.badge.bg-danger The workshop is full. %hr - if @workshop.waitlist_open? - - if @invitation.for_student? - = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| + = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| + - if role == 'Student' = f.input :tutorial, collection: @tutorial_titles, include_blank: true = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }, hint: 'Anything else we should know?', placeholder: 'e.g. I need help understanding selectors' - = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' - - else - = simple_form_for @invitation, url: invitation_waiting_list_path(@invitation), method: :post do |f| + - else = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 } - = f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0' + = hidden_field_tag :role, role + = f.button :button, button_label, class: 'btn btn-primary w-100 mb-0' - else %p= t('messages.waiting_list.closed') - else diff --git a/app/views/workshop_invitation/show.html.haml b/app/views/workshop_invitation/show.html.haml index b9224568f..9e536f22e 100644 --- a/app/views/workshop_invitation/show.html.haml +++ b/app/views/workshop_invitation/show.html.haml @@ -21,7 +21,7 @@ = render partial: 'shared/announcements', locals: { announcements: @announcements } .row - .col-12.col-md-7.col-lg-8 + .col-12.col-md-6 %p#introduction Hi #{@invitation.member.name}, = render partial: @invitation.role.downcase @@ -44,7 +44,7 @@ %li= t('workshop.virtual.invitation.info.line_5') %li= t('workshop.virtual.invitation.info.line_6') - .col-12.col-md-5.col-lg-4 + .col-12.col-md-6 .card.border-info.mb-3 - if @workshop.past? .card-body @@ -79,7 +79,29 @@ - else = @invitation.member.bans.active.first.reason - else - - if @invitation.for_coach? + - if @role_choice + %p + You are subscribed as both a student and a coach. + %strong Choose how you would like to attend. + .row + .col-12.col-md-6 + %h4 As a student + - if @workshop.event_student_spaces? + = simple_form_for @invitation, url: accept_invitation_path(@invitation), method: :post do |f| + = f.input :tutorial, collection: @tutorial_titles, include_blank: true + = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }, hint: 'Anything else we should know?', placeholder: 'e.g. I need help understanding selectors' + = f.button :button, 'Attend as a student', name: 'role', value: 'Student', class: 'btn btn-primary w-100 mb-0' + - else + = render partial: 'workshop_invitation/waiting_list', locals: { invitation: @invitation, role: 'Student', button_label: 'Join the student waiting list' } + .col-12.col-md-6 + %h4 As a coach + - if @workshop.event_coach_spaces? + = simple_form_for @invitation, url: accept_invitation_path(@invitation), method: :post do |f| + = f.input :note, required: false, input_html: { rows: 3, maxlength: 100 } + = f.button :button, 'Attend as a coach', name: 'role', value: 'Coach', class: 'btn btn-primary w-100 mb-0' + - else + = render partial: 'workshop_invitation/waiting_list', locals: { invitation: @invitation, role: 'Coach', button_label: 'Join the coach waiting list' } + - elsif @invitation.for_coach? - if @workshop.event_coach_spaces? = link_to 'Keep your skills up-to-date!', edit_member_path %span.d-block diff --git a/spec/controllers/check_ins_controller_spec.rb b/spec/controllers/check_ins_controller_spec.rb index 0a79402da..a14e0e153 100644 --- a/spec/controllers/check_ins_controller_spec.rb +++ b/spec/controllers/check_ins_controller_spec.rb @@ -31,7 +31,10 @@ end context 'when logged in' do - before { login(member) } + before do + login(member) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + end it 'creates an invitation with source=check_in' do post :create, params: { code: event.check_in_code, role: 'Student' } @@ -48,6 +51,16 @@ expect(flash[:alert]).to eq('Check-in is not currently open.') end + it 'keeps the role when check-in is refused' do + Fabricate(:invitation, event: future_event, member:, role: 'Student') + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + + post :create, params: { code: future_event.check_in_code, role: 'Coach' } + + expect(flash[:alert]).to eq('Check-in is not currently open.') + expect(Invitation.find_by(event: future_event, member:).role).to eq('Student') + end + it 'rejects an invalid role' do post :create, params: { code: event.check_in_code, role: 'Hacker' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) @@ -68,11 +81,62 @@ expect(Invitation.find(invitation.id).verified).to be true end - it 'rejects selecting a different role than the existing invitation' do + it 'checks in an undecided invitation in the chosen role' do Fabricate(:invitation, event:, member:, role: 'Student') + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + post :create, params: { code: event.check_in_code, role: 'Coach' } + invitation = Invitation.find_by(event:, member:) + expect(invitation.role).to eq('Coach') + expect(invitation.verified).to be true + expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) + end + + it 'rejects a role change when the member already accepted' do + Fabricate(:invitation, event:, member:, role: 'Student', attending: true) post :create, params: { code: event.check_in_code, role: 'Coach' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) expect(flash[:alert]).to include('Student') + expect(Invitation.find_by(event:, member:).role).to eq('Student') + end + + it 'rejects a role change when the member is on the waiting list' do + workshop = Fabricate(:workshop, + date_and_time: Time.zone.now - 30.minutes, + ends_at: Time.zone.now + 30.minutes) + invitation = Fabricate(:workshop_invitation, workshop:, member:, role: 'Student') + WaitingList.add(invitation) + post :create, params: { code: workshop.check_in_code, role: 'Coach' } + expect(response).to redirect_to(check_in_w_path(code: workshop.check_in_code)) + expect(flash[:alert]).to include('Student') + expect(invitation.reload.role).to eq('Student') + end + + it 'refuses check-in in a role the member is not subscribed to' do + post :create, params: { code: event.check_in_code, role: 'Coach' } + + expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) + expect(flash[:alert]).to eq('You are not subscribed as a Coach.') + end + + it 'refuses a role change to a role the member is not subscribed to' do + Fabricate(:invitation, event:, member:, role: 'Student') + + post :create, params: { code: event.check_in_code, role: 'Coach' } + + expect(flash[:alert]).to eq('You are not subscribed as a Coach.') + expect(Invitation.find_by(event:, member:).role).to eq('Student') + end + + it 'checks in an unsubscribed member in the role of their invitation' do + other = Fabricate(:member) + Fabricate(:invitation, event:, member: other, role: 'Coach') + login(other) + + post :create, params: { code: event.check_in_code, role: 'Coach' } + + invitation = Invitation.find_by(event:, member: other) + expect(invitation.role).to eq('Coach') + expect(invitation.verified).to be true end it 'rejects check-in when the role is at capacity' do diff --git a/spec/controllers/invitations_controller_spec.rb b/spec/controllers/invitations_controller_spec.rb index cbd789f8a..13a0a5520 100644 --- a/spec/controllers/invitations_controller_spec.rb +++ b/spec/controllers/invitations_controller_spec.rb @@ -10,6 +10,47 @@ expect(response).to have_http_status(:not_found) end end + + context 'when the member is subscribed as both student and coach' do + render_views + + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + before do + member = invitation.member + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + end + + it 'renders the role choice to a dual-role member who has not RSVPd' do + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).to include('Choose how you would like to attend') + expect(response.body).to include('RSVP as a student') + expect(response.body).to include('RSVP as a coach') + end + + it 'renders no role choice when the member already attends the event in another role' do + Fabricate(:attending_event_invitation, event:, member: invitation.member, role: 'Coach') + + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).not_to include('Choose how you would like to attend') + expect(response.body).to include("You have already RSVP'd to this event as a coach") + end + end + + context 'when the member is subscribed in a single role' do + render_views + + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + it 'renders no role choice' do + get :show, params: { event_id: event.id, token: invitation.token } + + expect(response.body).not_to include('Choose how you would like to attend') + end + end end describe 'POST #attend' do @@ -50,6 +91,57 @@ expect(invitation.reload.attending).to be true end end + + context 'when the member chooses a role' do + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + before { Fabricate(:subscription, member: invitation.member, group: Fabricate(:group, name: 'Coaches')) } + + it 'accepts in the chosen role' do + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + it 'keeps the invitation role when the chosen role is full' do + event.update!(coach_spaces: 0) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).not_to be true + end + + it 'does not change the role of an invitation that is already accepted' do + invitation.update!(attending: true) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + end + + it 'lets a cancelled member choose a role again' do + invitation.update!(attending: true, verified: true, role: 'Student') + invitation.update!(attending: false) + + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + end + + context 'when the member is not subscribed to the chosen role' do + let(:invitation) { Fabricate(:invitation, event:, role: 'Student') } + + it 'keeps the invitation role and accepts' do + post :attend, params: { event_id: event.id, token: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + end end describe 'POST #reject' do diff --git a/spec/controllers/waiting_lists_controller_spec.rb b/spec/controllers/waiting_lists_controller_spec.rb index 474d066d2..a75c48947 100644 --- a/spec/controllers/waiting_lists_controller_spec.rb +++ b/spec/controllers/waiting_lists_controller_spec.rb @@ -33,6 +33,25 @@ expect(WaitingList.where(invitation:).count).to eq(1) end + context 'when the member chooses a role' do + before { Fabricate(:subscription, member: invitation.member, group: Fabricate(:group, name: 'Coaches')) } + + it 'joins the waiting list for the chosen role' do + post :create, params: { invitation_id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.waiting_list).to be_present + end + + it 'does not change the role of an invitation already on the waiting list' do + post :create, params: { invitation_id: invitation.token } + + post :create, params: { invitation_id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + end + end + context 'without a CSRF token (browser did not send session cookie)' do # Simulate the real-world scenario where the browser withholds the # session cookie (e.g. Safari/WebKit ITP on cross-site navigation). diff --git a/spec/controllers/workshop_invitation_controller_spec.rb b/spec/controllers/workshop_invitation_controller_spec.rb index d230ed78f..3deed45c8 100644 --- a/spec/controllers/workshop_invitation_controller_spec.rb +++ b/spec/controllers/workshop_invitation_controller_spec.rb @@ -80,6 +80,16 @@ post :accept, params: { id: invitation.token } expect(flash[:notice]).to include('no more spots left') end + + it 'keeps the invitation role when the chosen role is full' do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + invitation.update!(role: 'Coach') + + post :accept, params: { id: invitation.token, role: 'Student' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be_nil + end end context 'when tutorial is missing for student' do @@ -108,6 +118,52 @@ expect(invitation.reload.attending).to be true end end + + context 'when the member chooses a role' do + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + + it 'accepts in the chosen role' do + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + it 'ignores an unknown role and keeps the invitation role' do + post :accept, params: { id: invitation.token, role: 'Hacker' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + + it 'does not change the role of an invitation that is already accepted' do + invitation.update!(attending: true) + + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + end + + it 'keeps the chosen role when the accept is invalid' do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + invitation.update!(tutorial: nil, role: 'Coach') + + post :accept, params: { id: invitation.token, role: 'Student' } + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be_nil + expect(flash[:notice].join).to include('Tutorial') + end + end + + context 'when the member is not subscribed to the chosen role' do + it 'keeps the invitation role and accepts' do + post :accept, params: { id: invitation.token, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(invitation.attending).to be true + end + end end describe 'POST #reject' do diff --git a/spec/controllers/workshops_controller_spec.rb b/spec/controllers/workshops_controller_spec.rb index f08b6dd0b..df0600832 100644 --- a/spec/controllers/workshops_controller_spec.rb +++ b/spec/controllers/workshops_controller_spec.rb @@ -60,6 +60,8 @@ Fabricate(:workshop_invitation, workshop:, member:, role: 'Coach', attending: nil) end + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'redirects to the existing invitation page' do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -78,6 +80,8 @@ Fabricate(:workshop_invitation, workshop:, member:, role: 'Student', attending: nil) end + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'updates the existing invitation to the requested role instead of creating a second one' do expect do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -89,6 +93,8 @@ end context 'when the member does not have an invitation for the workshop and role' do + before { Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) } + it 'creates a new invitation and redirects' do expect do post :rsvp, params: { id: workshop.id, role: 'Coach' } @@ -99,6 +105,19 @@ end end + context 'when the member is not subscribed to the requested role' do + let!(:invitation) do + Fabricate(:workshop_invitation, workshop:, member:, role: 'Student', attending: nil) + end + + it 'keeps the invitation role' do + post :rsvp, params: { id: workshop.id, role: 'Coach' } + + expect(invitation.reload.role).to eq('Student') + expect(flash[:notice]).to eq('You are not subscribed as a Coach.') + end + end + context 'when the member is already attending' do before do Fabricate(:attending_workshop_invitation, workshop:, member:, role: 'Coach') diff --git a/spec/features/accepting_invitation_spec.rb b/spec/features/accepting_invitation_spec.rb index d42a8dd4e..df88be711 100644 --- a/spec/features/accepting_invitation_spec.rb +++ b/spec/features/accepting_invitation_spec.rb @@ -24,6 +24,48 @@ it_behaves_like 'invitation route' + context 'when the member is subscribed as both student and coach' do + before do + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Students')) + Fabricate(:subscription, member:, group: Fabricate(:group, name: 'Coaches')) + end + + scenario 'offers the choice of role' do + visit invitation_route + + expect(page).to have_text('Choose how you would like to attend') + expect(page).to have_button('Attend as a student') + expect(page).to have_button('Attend as a coach') + end + + scenario 'accepts in the chosen role' do + visit invitation_route + click_on 'Attend as a coach' + + expect(invitation.reload.role).to eq('Coach') + expect(invitation.attending).to be true + end + + scenario 'joins the waiting list for the chosen role when that role is full' do + set_no_available_slots + + visit invitation_route + click_on 'Join the student waiting list' + + expect(invitation.reload.role).to eq('Student') + expect(invitation.waiting_list).to be_present + end + end + + context 'when the member is subscribed in one role' do + scenario 'shows no role choice' do + visit invitation_route + + expect(page).to have_button('Attend') + expect(page).to have_no_text('Choose how you would like to attend') + end + end + context 'when workshop is full' do scenario 'shows waiting list option when there are no available seats' do set_no_available_slots diff --git a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb index ae9d9ea88..0a99332b6 100644 --- a/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb +++ b/spec/support/shared_examples/behaves_like_managing_workshop_attendance.rb @@ -61,7 +61,8 @@ click_on 'Attend as a student' select tutorial.title, from: :workshop_invitation_tutorial - click_on 'Attend' + # Same label as the workshop page: the first press opens the form, this one submits it. + click_on 'Attend as a student' expect(page).to have_text('See you at the workshop') @@ -71,7 +72,8 @@ it 'can get to the RSVP as a coach page' do click_on 'Attend as a coach' - click_on 'Attend' + # Same label as the workshop page: the first press opens the form, this one submits it. + click_on 'Attend as a coach' expect(page).to have_text('See you at the workshop') @@ -93,7 +95,8 @@ visit workshop_path(workshop) click_on 'Attend as a coach' - click_on 'Attend' + # Same label as the workshop page: the first press opens the form, this one submits it. + click_on 'Attend as a coach' visit workshop_path(workshop)