From 41fb6f9a584a06bec24eb7b2f561f9b73c42f961 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Wed, 4 Mar 2026 09:39:21 -0800 Subject: [PATCH 01/26] Testing file v1 --- spec/features/event_duplication_spec.rb | 283 ++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 spec/features/event_duplication_spec.rb diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb new file mode 100644 index 000000000..3d64666f8 --- /dev/null +++ b/spec/features/event_duplication_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: events +# +# id :bigint not null, primary key +# abstract :text +# comments_count :integer default(0), not null +# committee_review :text +# description :text +# guid :string not null +# is_highlight :boolean default(FALSE) +# language :string +# max_attendees :integer +# presentation_mode :integer +# progress :string default("new"), not null +# proposal_additional_speakers :text +# public :boolean default(TRUE) +# require_registration :boolean +# start_time :datetime +# state :string default("new"), not null +# submission_text :text +# subtitle :string +# superevent :boolean +# title :string not null +# week :integer +# created_at :datetime +# updated_at :datetime +# difficulty_level_id :integer +# event_type_id :integer +# parent_id :integer +# program_id :integer +# room_id :integer +# track_id :integer +# +# Foreign Keys +# +# fk_rails_... (parent_id => events.id) +# +require 'spec_helper' + +describe EventDuplicator do + let(:conference) { create(:conference) } + let(:track) { create(:track, state: 'confirmed', program: conference.program) } + let(:event_type) { create(:event_type, program: conference.program) } + + let(:original_event) do + create(:event, + program: conference.program, + title: 'Coffee Break', + abstract: 'A short break for coffee', + description: 'Attendees are encouraged to mingle', + event_type: event_type, + track: track, + require_registration: false, + max_attendees: 20, + state: 'confirmed', + start_time: Time.current) + end + + before do + create(:vote, event: original_event, user: create(:user)) + create(:comment, commentable: original_event) + original_event.registrations << create(:registration) + end + + subject(:duplicator) { described_class.new(original_event) } + + describe '#duplicate' do + subject(:duplicate) { duplicator.duplicate } + + it 'returns a new, persisted Event' do + expect(duplicate).to be_a(Event) + expect(duplicate).to be_persisted + end + + it 'creates a distinct record with new id' do + expect(duplicate.id).not_to eq original_event.id + end + + describe 'copied fields' do + it 'copies the title' do + expect(duplicate.title).to eq original_event.title + end + + it 'copies the abstract' do + expect(duplicate.abstract).to eq original_event.abstract + end + + it 'copies the description' do + expect(duplicate.description).to eq original_event.description + end + + it 'copies the event_type' do + expect(duplicate.event_type).to eq original_event.event_type + end + + it 'copies the track' do + expect(duplicate.track).to eq original_event.track + end + + it 'copies require_registration' do + expect(duplicate.require_registration).to eq original_event.require_registration + end + + it 'copies max_attendees' do + expect(duplicate.max_attendees).to eq original_event.max_attendees + end + end + + describe 'reset fields' do + before do + venue = create(:venue, conference: conference) + room = create(:room, venue: venue) + create(:event_schedule, event: original_event, room: room) + end + + it 'does not copy start_time' do + expect(duplicate.start_time).to be_nil + end + + it 'resets state to "new"' do + expect(duplicate.state).to eq 'new' + end + + it 'has no room assigned' do + expect(duplicate.room_id).to be_nil + end + + it 'has no parent assigned' do + child_event = create(:event, program: conference.program, parent_id: original_event.id) + duplicate_child = described_class.new(child_event).duplicate + expect(duplicate_child.parent_id).to be_nil + end + + it 'generates a new unique guid' do + expect(duplicate.guid).not_to eq original_event.guid + expect(duplicate.guid).to be_present + end + end + + describe 'excluded attendee data' do + it 'has no registrations' do + expect(duplicate.registrations).to be_empty + end + + it 'has no votes' do + expect(duplicate.votes).to be_empty + end + + it 'has no comments' do + expect(duplicate.comment_threads).to be_empty + end + end + + describe 'belongs to the same program' do + it 'is scoped to the same conference program' do + expect(duplicate.program).to eq original_event.program + end + end + end +end + +require 'spec_helper' + +describe 'Scheduling a duplicated event' do + let(:conference) do + create(:conference, + start_date: Date.today, + end_date: Date.today + 2, + start_hour: 9, + end_hour: 20) + end + let(:schedule) { create(:schedule, program: conference.program) } + let(:venue) { conference.venue || create(:venue, conference: conference) } + let(:room) { create(:room, venue: venue) } + + let(:original_start_time) { Time.current.change(hour: 10, min: 0) } + let(:duplicate_start_time) { Time.current.change(hour: 14, min: 0) } + + let(:original_event) do + create(:event, state: 'confirmed', program: conference.program) + end + + let(:duplicate_event) do + EventDuplicator.new(original_event).duplicate + end + + let!(:original_schedule) do + create(:event_schedule, + event: original_event, + schedule: schedule, + room: room, + start_time: original_start_time) + end + + describe 'the duplicate event' do + it 'starts with no event_schedules' do + expect(duplicate_event.event_schedules).to be_empty + end + + it 'is not scheduled before a time is assigned' do + expect(duplicate_event.scheduled?).to be false + end + + context 'after being given a new start_time via EventSchedule' do + let!(:duplicate_schedule) do + create(:event_schedule, + event: duplicate_event, + schedule: schedule, + room: room, + start_time: duplicate_start_time) + end + + it 'is scheduled' do + expect(duplicate_event.scheduled?).to be true + end + + it 'has a different start_time than the original' do + expect(duplicate_schedule.start_time).not_to eq original_schedule.start_time + end + + it 'has the correct start_time' do + expect(duplicate_schedule.start_time).to eq duplicate_start_time + end + + it 'is a valid event_schedule' do + expect(duplicate_schedule).to be_valid + end + end + end + + describe 'the original event' do + before do + create(:event_schedule, + event: duplicate_event, + schedule: schedule, + room: room, + start_time: duplicate_start_time) + end + + it 'remains scheduled' do + expect(original_event.scheduled?).to be true + end + + it 'retains its original start_time' do + expect(original_schedule.start_time).to eq original_start_time + end + + it 'still has exactly one event_schedule' do + expect(original_event.event_schedules.count).to eq 1 + end + end + + describe 'both events scheduled independently' do + let!(:duplicate_schedule) do + create(:event_schedule, + event: duplicate_event, + schedule: schedule, + room: room, + start_time: duplicate_start_time) + end + + it 'both are scheduled' do + expect(original_event.scheduled?).to be true + expect(duplicate_event.scheduled?).to be true + end + + it 'do not share the same start_time' do + expect(original_schedule.start_time).not_to eq duplicate_schedule.start_time + end + + it 'each has exactly one event_schedule' do + expect(original_event.event_schedules.count).to eq 1 + expect(duplicate_event.event_schedules.count).to eq 1 + end + + it 'neither event_schedule is the same record' do + expect(original_schedule.id).not_to eq duplicate_schedule.id + end + end +end \ No newline at end of file From b4339cca003c58b30941fff230ea05cada93de9c Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 17:36:56 -0800 Subject: [PATCH 02/26] First changes on Event Duplication --- app/controllers/admin/events_controller.rb | 10 +++++++++ app/services/event_duplicator.rb | 24 ++++++++++++++++++++++ app/views/admin/events/_proposal.html.haml | 1 + config/routes.rb | 1 + spec/features/event_duplication_spec.rb | 2 +- 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 app/services/event_duplicator.rb diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 939b4d967..67431be9e 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -182,6 +182,16 @@ def toggle_attendance end end + def duplicate + duplicator = EventDuplicator.new(@event) + new_event = duplicator.duplicate + flash[:notice] = "Event '#{new_event.title}' duplicated successfully." + redirect_to admin_conference_program_event_path(@conference.short_title, new_event) + rescue StandardError => e + flash[:alert] = "Could not duplicate event: #{e.message}" + redirect_to admin_conference_program_event_path(@conference.short_title, @event) + end + def destroy @event = Event.find(params[:id]) if @event.destroy diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb new file mode 100644 index 000000000..b47737fb6 --- /dev/null +++ b/app/services/event_duplicator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class EventDuplicator + def initialize(event) + @event = event + end + + def duplicate + Event.create!( + title: @event.title, + abstract: @event.abstract, + description: @event.description, + event_type: @event.event_type, + track: @event.track, + require_registration: @event.require_registration, + max_attendees: @event.max_attendees, + program: @event.program, + state: 'new', + start_time: nil, + room_id: nil, + parent_id: nil + ) + end +end diff --git a/app/views/admin/events/_proposal.html.haml b/app/views/admin/events/_proposal.html.haml index 696306856..379b50eff 100644 --- a/app/views/admin/events/_proposal.html.haml +++ b/app/views/admin/events/_proposal.html.haml @@ -10,6 +10,7 @@ - if @event.public = link_to 'Preview', conference_program_proposal_path(@conference.short_title, @event.id), class: 'btn btn-mini btn-primary' = link_to 'Registrations', registrations_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-success' + = link_to 'Duplicate', admin_conference_program_event_duplicate_path(@conference.short_title, @event), method: :post, class: 'btn btn-mini btn-info' = link_to 'Edit', edit_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-mini btn-primary' = link_to 'Delete', admin_conference_program_event_path(@conference.short_title, @event), method: :delete, data: { confirm: 'Are you sure you want to delete this event?' }, class: 'btn btn-mini btn-danger' diff --git a/config/routes.rb b/config/routes.rb index 62ac3dfdd..4285ff58e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -119,6 +119,7 @@ patch :unconfirm patch :restart get :vote + post :duplicate end end resources :reports, only: :index diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 3d64666f8..9e5c0c6e3 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -162,7 +162,7 @@ end end -require 'spec_helper' +########## Middle of test suite describe 'Scheduling a duplicated event' do let(:conference) do From df9fde6d004a6fac77a761514ef7dc94643219e9 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 19:24:10 -0800 Subject: [PATCH 03/26] Duplication and tests added --- app/controllers/admin/events_controller.rb | 17 +- app/services/event_duplicator.rb | 70 ++- app/views/admin/events/_proposal.html.haml | 3 +- app/views/admin/events/show.html.haml | 20 + .../events_duplication_controller_spec.rb | 347 ++++++++++++++ spec/features/event_duplication_spec.rb | 429 +++++++----------- 6 files changed, 603 insertions(+), 283 deletions(-) create mode 100644 spec/controllers/admin/events_duplication_controller_spec.rb diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 67431be9e..d2482f55d 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -183,10 +183,19 @@ def toggle_attendance end def duplicate - duplicator = EventDuplicator.new(@event) - new_event = duplicator.duplicate - flash[:notice] = "Event '#{new_event.title}' duplicated successfully." - redirect_to admin_conference_program_event_path(@conference.short_title, new_event) + count = (params[:count] || 1).to_i + count = 1 if count < 1 || count > 100 # Limit to reasonable number + + duplicator = EventDuplicator.new(@event, current_user) + duplicated_events = duplicator.duplicate(count) + + if duplicated_events.length == 1 + flash[:notice] = "Event '#{duplicated_events.first.title}' duplicated successfully." + redirect_to admin_conference_program_event_path(@conference.short_title, duplicated_events.first) + else + flash[:notice] = "#{duplicated_events.length} copies of '#{@event.title}' created successfully." + redirect_to admin_conference_program_events_path(@conference.short_title) + end rescue StandardError => e flash[:alert] = "Could not duplicate event: #{e.message}" redirect_to admin_conference_program_event_path(@conference.short_title, @event) diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb index b47737fb6..d5b938b03 100644 --- a/app/services/event_duplicator.rb +++ b/app/services/event_duplicator.rb @@ -1,24 +1,64 @@ # frozen_string_literal: true class EventDuplicator - def initialize(event) + def initialize(event, submitter = nil) @event = event + @submitter = submitter end - def duplicate - Event.create!( - title: @event.title, - abstract: @event.abstract, - description: @event.description, - event_type: @event.event_type, - track: @event.track, - require_registration: @event.require_registration, - max_attendees: @event.max_attendees, - program: @event.program, - state: 'new', - start_time: nil, - room_id: nil, - parent_id: nil + def duplicate(count = 1) + duplicated_events = [] + count.times do + duplicated_events << create_duplicate + end + duplicated_events + end + + private + + def create_duplicate + duplicate_event = Event.create!( + # Content fields + title: @event.title, + subtitle: @event.subtitle, + abstract: @event.abstract, + description: @event.description, + submission_text: @event.submission_text, + committee_review: @event.committee_review, + proposal_additional_speakers: @event.proposal_additional_speakers, + + # Configuration fields + event_type: @event.event_type, + track: @event.track, + difficulty_level: @event.difficulty_level, + language: @event.language, + presentation_mode: @event.presentation_mode, + + # Registration and attendance + require_registration: @event.require_registration, + max_attendees: @event.max_attendees, + + # Status fields + program: @event.program, + state: 'new', + progress: @event.progress, + public: @event.public, + is_highlight: @event.is_highlight, + + # Don't copy: start_time, room_id, parent_id (reset to nil) + start_time: nil, + room_id: nil, + parent_id: nil, + + # Submitter + submitter: @submitter ) + + # Copy speakers and volunteers + duplicate_event.speakers = @event.speakers + duplicate_event.volunteers = @event.volunteers + duplicate_event.save! + + duplicate_event end end diff --git a/app/views/admin/events/_proposal.html.haml b/app/views/admin/events/_proposal.html.haml index 379b50eff..26d9542ae 100644 --- a/app/views/admin/events/_proposal.html.haml +++ b/app/views/admin/events/_proposal.html.haml @@ -10,7 +10,8 @@ - if @event.public = link_to 'Preview', conference_program_proposal_path(@conference.short_title, @event.id), class: 'btn btn-mini btn-primary' = link_to 'Registrations', registrations_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-success' - = link_to 'Duplicate', admin_conference_program_event_duplicate_path(@conference.short_title, @event), method: :post, class: 'btn btn-mini btn-info' + %button.btn.btn-mini.btn-info{'data-toggle' => 'modal', 'data-target' => '#duplicateEventModal'} + Duplicate = link_to 'Edit', edit_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-mini btn-primary' = link_to 'Delete', admin_conference_program_event_path(@conference.short_title, @event), method: :delete, data: { confirm: 'Are you sure you want to delete this event?' }, class: 'btn btn-mini btn-danger' diff --git a/app/views/admin/events/show.html.haml b/app/views/admin/events/show.html.haml index 6cd270117..3a0b42348 100644 --- a/app/views/admin/events/show.html.haml +++ b/app/views/admin/events/show.html.haml @@ -67,3 +67,23 @@ #proposal-commercials.tab-pane = render partial: 'shared/media_items', locals: { commercials: @event.commercials } + +#duplicateEventModal.modal.fade{tabindex: '-1', role: 'dialog', 'aria-labelledby' => 'duplicateEventModalLabel'} + .modal-dialog{role: 'document'} + .modal-content + .modal-header + %button.close{'data-dismiss' => 'modal', 'aria-label' => 'Close'} + %span{'aria-hidden' => 'true'} + × + %h4#duplicateEventModalLabel.modal-title + Duplicate Event + = form_with url: duplicate_admin_conference_program_event_path(@conference.short_title, @event), method: :post, local: true do |f| + .modal-body + .form-group + %label{for: 'duplicate_count'} Number of copies to create: + %input#duplicate_count.form-control{type: 'number', name: 'count', value: '1', min: '1', max: '100', required: true} + %small.form-text.text-muted + You can create up to 100 copies at once. The new events will be created with you as the submitter. + .modal-footer + %button.btn.btn-secondary{'data-dismiss' => 'modal'} Cancel + %button.btn.btn-primary{type: 'submit'} Create Copies diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb new file mode 100644 index 000000000..d2c65fb22 --- /dev/null +++ b/spec/controllers/admin/events_duplication_controller_spec.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Admin::EventsController, type: :controller do + let(:conference) { create(:conference, short_title: 'osem2023') } + let(:program) { conference.program } + let(:user) { create(:admin) } + let(:event_type) { create(:event_type, program: program) } + let(:track) { create(:track, state: 'confirmed', program: program) } + let(:difficulty_level) { create(:difficulty_level, program: program) } + + let(:speaker) { create(:user) } + let(:volunteer) { create(:user) } + + let(:original_event) do + event = create(:event, + program: program, + title: 'Original Event', + abstract: 'An abstract', + description: 'A description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, + require_registration: true, + max_attendees: 50, + state: 'confirmed') + event.speakers << speaker + event.volunteers << volunteer + event + end + + before do + sign_in user + end + + describe '#duplicate' do + context 'with single duplicate' do + it 'creates one copy of the event' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'redirects to the new event page' do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + expect(response).to redirect_to(admin_conference_program_event_path(conference.short_title, Event.last)) + end + + it 'sets a success flash message' do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + expect(flash[:notice]).to include('duplicated successfully') + end + + it 'assigns the current user as submitter' do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + new_event = Event.last + expect(new_event.submitter).to eq user + end + end + + context 'with multiple duplicates' do + it 'creates the requested number of copies' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 3 + } + end.to change(Event, :count).by(3) + end + + it 'redirects to the events index when creating multiple' do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 3 + } + expect(response).to redirect_to(admin_conference_program_events_path(conference.short_title)) + end + + it 'shows the count of created copies in flash message' do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 3 + } + expect(flash[:notice]).to include('3 copies') + end + end + + context 'with invalid count' do + it 'defaults to 1 if count is 0' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 0 + } + end.to change(Event, :count).by(1) + end + + it 'caps at 100 if count is too high' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 200 + } + end.to change(Event, :count).by(100) + end + + it 'defaults to 1 if count is missing' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id + } + end.to change(Event, :count).by(1) + end + end + + context 'field copying' do + before do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + @duplicate = Event.last + end + + it 'copies content fields' do + expect(@duplicate.title).to eq original_event.title + expect(@duplicate.abstract).to eq original_event.abstract + expect(@duplicate.description).to eq original_event.description + expect(@duplicate.subtitle).to eq original_event.subtitle + end + + it 'copies configuration fields' do + expect(@duplicate.event_type).to eq original_event.event_type + expect(@duplicate.track).to eq original_event.track + expect(@duplicate.difficulty_level).to eq original_event.difficulty_level + expect(@duplicate.language).to eq original_event.language + end + + it 'copies registration settings' do + expect(@duplicate.require_registration).to eq original_event.require_registration + expect(@duplicate.max_attendees).to eq original_event.max_attendees + end + + it 'copies speakers and volunteers' do + expect(@duplicate.speakers).to include(speaker) + expect(@duplicate.volunteers).to include(volunteer) + end + + it 'copies public status' do + expect(@duplicate.public).to eq original_event.public + end + + it 'sets new event state' do + expect(@duplicate.state).to eq 'new' + end + + it 'generates new guid' do + expect(@duplicate.guid).not_to eq original_event.guid + expect(@duplicate.guid).to be_present + end + + it 'does not copy start_time' do + expect(@duplicate.start_time).to be_nil + end + + it 'does not copy room' do + expect(@duplicate.room_id).to be_nil + end + + it 'does not have parent' do + expect(@duplicate.parent_id).to be_nil + end + + it 'does not copy registrations' do + create(:registration) + original_event.registrations << Registration.first + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + new_duplicate = Event.where(id: Event.last.id).first + expect(new_duplicate.registrations).to be_empty + end + + it 'does not copy votes' do + create(:vote, event: original_event) + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + new_duplicate = Event.where(id: Event.last.id).first + expect(new_duplicate.votes).to be_empty + end + + it 'does not copy comments' do + create(:comment, commentable: original_event) + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + new_duplicate = Event.where(id: Event.last.id).first + expect(new_duplicate.comment_threads).to be_empty + end + end + + context 'duplicate of duplicate' do + before do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 1 + } + @first_duplicate = Event.last + end + + it 'can duplicate a duplicate' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: @first_duplicate.id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'preserves all fields when duplicating a duplicate' do + post :duplicate, params: { + conference_id: conference.short_title, + id: @first_duplicate.id, + count: 1 + } + second_duplicate = Event.last + + expect(second_duplicate.title).to eq original_event.title + expect(second_duplicate.speakers).to include(speaker) + expect(second_duplicate.volunteers).to include(volunteer) + expect(second_duplicate.difficulty_level).to eq original_event.difficulty_level + end + end + + context 'deletion independence' do + before do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 3 + } + @duplicates = Event.where(title: original_event.title).order(:created_at).last(3) + end + + it 'deleting a duplicate does not delete other duplicates' do + duplicate_to_delete = @duplicates.first + + expect do + delete :destroy, params: { + conference_id: conference.short_title, + id: duplicate_to_delete.id + } + end.to change(Event, :count).by(-1) + + expect(Event.exists?(@duplicates[1].id)).to be true + expect(Event.exists?(@duplicates[2].id)).to be true + end + + it 'deleting the original does not delete duplicates' do + expect do + delete :destroy, params: { + conference_id: conference.short_title, + id: original_event.id + } + end.to change(Event, :count).by(-1) + + @duplicates.each do |duplicate| + expect(Event.exists?(duplicate.id)).to be true + end + end + end + + context 'update independence' do + before do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 2 + } + @duplicates = Event.where(title: original_event.title).order(:created_at).last(2) + end + + it 'updating original does not affect duplicates' do + new_title = 'Updated Original Title' + original_event.update(title: new_title) + + @duplicates.each do |duplicate| + duplicate.reload + expect(duplicate.title).not_to eq new_title + expect(duplicate.title).to eq original_event.title.sub(new_title, original_event.title) + end + end + + it 'updating a duplicate does not affect original' do + duplicate = @duplicates.first + new_title = 'Updated Duplicate Title' + duplicate.update(title: new_title) + + original_event.reload + expect(original_event.title).not_to eq new_title + end + + it 'updating a duplicate does not affect other duplicates' do + first_duplicate = @duplicates.first + second_duplicate = @duplicates.last + + first_duplicate.update(max_attendees: 100) + + second_duplicate.reload + expect(second_duplicate.max_attendees).to eq original_event.max_attendees + end + end + end +end diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 9e5c0c6e3..c82b60113 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -1,283 +1,186 @@ # frozen_string_literal: true -# == Schema Information -# -# Table name: events -# -# id :bigint not null, primary key -# abstract :text -# comments_count :integer default(0), not null -# committee_review :text -# description :text -# guid :string not null -# is_highlight :boolean default(FALSE) -# language :string -# max_attendees :integer -# presentation_mode :integer -# progress :string default("new"), not null -# proposal_additional_speakers :text -# public :boolean default(TRUE) -# require_registration :boolean -# start_time :datetime -# state :string default("new"), not null -# submission_text :text -# subtitle :string -# superevent :boolean -# title :string not null -# week :integer -# created_at :datetime -# updated_at :datetime -# difficulty_level_id :integer -# event_type_id :integer -# parent_id :integer -# program_id :integer -# room_id :integer -# track_id :integer -# -# Foreign Keys -# -# fk_rails_... (parent_id => events.id) -# require 'spec_helper' -describe EventDuplicator do - let(:conference) { create(:conference) } - let(:track) { create(:track, state: 'confirmed', program: conference.program) } - let(:event_type) { create(:event_type, program: conference.program) } +describe 'Event Duplication Feature', :js do + let(:conference) { create(:full_conference, short_title: 'osem2023') } + let(:program) { conference.program } + let(:admin_user) { create(:admin, email: 'admin@osem.io', password: 'password123') } + let(:event_type) { create(:event_type, program: program) } + let(:track) { create(:track, state: 'confirmed', program: program) } + let(:difficulty_level) { create(:difficulty_level, program: program) } + + let(:speaker) { create(:user) } + let(:venue) { conference.venue || create(:venue, conference: conference) } + let(:room) { create(:room, venue: venue) } let(:original_event) do - create(:event, - program: conference.program, - title: 'Coffee Break', - abstract: 'A short break for coffee', - description: 'Attendees are encouraged to mingle', - event_type: event_type, - track: track, - require_registration: false, - max_attendees: 20, - state: 'confirmed', - start_time: Time.current) + event = create(:event, + program: program, + title: 'Test Event for Duplication', + abstract: 'Event abstract', + description: 'Event description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, + require_registration: true, + max_attendees: 50, + state: 'confirmed') + event.speakers << speaker + event end before do - create(:vote, event: original_event, user: create(:user)) - create(:comment, commentable: original_event) - original_event.registrations << create(:registration) + login_as admin_user, scope: :user end - subject(:duplicator) { described_class.new(original_event) } - - describe '#duplicate' do - subject(:duplicate) { duplicator.duplicate } - - it 'returns a new, persisted Event' do - expect(duplicate).to be_a(Event) - expect(duplicate).to be_persisted - end - - it 'creates a distinct record with new id' do - expect(duplicate.id).not_to eq original_event.id - end - - describe 'copied fields' do - it 'copies the title' do - expect(duplicate.title).to eq original_event.title - end - - it 'copies the abstract' do - expect(duplicate.abstract).to eq original_event.abstract - end - - it 'copies the description' do - expect(duplicate.description).to eq original_event.description - end - - it 'copies the event_type' do - expect(duplicate.event_type).to eq original_event.event_type - end - - it 'copies the track' do - expect(duplicate.track).to eq original_event.track - end - - it 'copies require_registration' do - expect(duplicate.require_registration).to eq original_event.require_registration - end - - it 'copies max_attendees' do - expect(duplicate.max_attendees).to eq original_event.max_attendees - end - end - - describe 'reset fields' do - before do - venue = create(:venue, conference: conference) - room = create(:room, venue: venue) - create(:event_schedule, event: original_event, room: room) - end - - it 'does not copy start_time' do - expect(duplicate.start_time).to be_nil - end - - it 'resets state to "new"' do - expect(duplicate.state).to eq 'new' - end - - it 'has no room assigned' do - expect(duplicate.room_id).to be_nil - end - - it 'has no parent assigned' do - child_event = create(:event, program: conference.program, parent_id: original_event.id) - duplicate_child = described_class.new(child_event).duplicate - expect(duplicate_child.parent_id).to be_nil - end - - it 'generates a new unique guid' do - expect(duplicate.guid).not_to eq original_event.guid - expect(duplicate.guid).to be_present - end - end - - describe 'excluded attendee data' do - it 'has no registrations' do - expect(duplicate.registrations).to be_empty - end - - it 'has no votes' do - expect(duplicate.votes).to be_empty - end - - it 'has no comments' do - expect(duplicate.comment_threads).to be_empty - end - end - - describe 'belongs to the same program' do - it 'is scoped to the same conference program' do - expect(duplicate.program).to eq original_event.program - end - end - end -end - -########## Middle of test suite - -describe 'Scheduling a duplicated event' do - let(:conference) do - create(:conference, - start_date: Date.today, - end_date: Date.today + 2, - start_hour: 9, - end_hour: 20) - end - let(:schedule) { create(:schedule, program: conference.program) } - let(:venue) { conference.venue || create(:venue, conference: conference) } - let(:room) { create(:room, venue: venue) } - - let(:original_start_time) { Time.current.change(hour: 10, min: 0) } - let(:duplicate_start_time) { Time.current.change(hour: 14, min: 0) } - - let(:original_event) do - create(:event, state: 'confirmed', program: conference.program) - end - - let(:duplicate_event) do - EventDuplicator.new(original_event).duplicate - end - - let!(:original_schedule) do - create(:event_schedule, - event: original_event, - schedule: schedule, - room: room, - start_time: original_start_time) - end - - describe 'the duplicate event' do - it 'starts with no event_schedules' do - expect(duplicate_event.event_schedules).to be_empty - end - - it 'is not scheduled before a time is assigned' do - expect(duplicate_event.scheduled?).to be false - end - - context 'after being given a new start_time via EventSchedule' do - let!(:duplicate_schedule) do - create(:event_schedule, - event: duplicate_event, - schedule: schedule, - room: room, - start_time: duplicate_start_time) - end - - it 'is scheduled' do - expect(duplicate_event.scheduled?).to be true - end - - it 'has a different start_time than the original' do - expect(duplicate_schedule.start_time).not_to eq original_schedule.start_time - end - - it 'has the correct start_time' do - expect(duplicate_schedule.start_time).to eq duplicate_start_time - end - - it 'is a valid event_schedule' do - expect(duplicate_schedule).to be_valid - end + describe 'duplicating an event via web interface' do + it 'shows duplicate button on event details page' do + visit admin_conference_program_event_path(conference.short_title, original_event) + expect(page).to have_button('Duplicate') + end + + it 'opens the duplicate modal when clicking duplicate button' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + expect(page).to have_css('#duplicateEventModal.in') + expect(page).to have_field('count') + end + + it 'creates one copy by default' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + expect(page).to have_content('duplicated successfully') + expect(Event.where(title: original_event.title).count).to eq 2 + end + + it 'creates multiple copies when specified' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + + fill_in('count', with: 5) + click_button('Create Copies') + + expect(page).to have_content('5 copies') + expect(Event.where(title: original_event.title).count).to eq 6 + end + + it 'sets the current user as submitter of duplicates' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) + duplicates.each do |dup| + expect(dup.submitter).to eq admin_user + end + end + + it 'preserves all event fields in duplicate' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + duplicate = Event.where(title: original_event.title).where.not(id: original_event.id).first + + expect(duplicate.abstract).to eq original_event.abstract + expect(duplicate.description).to eq original_event.description + expect(duplicate.event_type).to eq original_event.event_type + expect(duplicate.track).to eq original_event.track + expect(duplicate.difficulty_level).to eq original_event.difficulty_level + expect(duplicate.require_registration).to eq original_event.require_registration + expect(duplicate.max_attendees).to eq original_event.max_attendees + expect(duplicate.speakers).to include(speaker) + end + + it 'shows the duplicate in the events list' do + original_count = program.events.count + + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + visit admin_conference_program_events_path(conference.short_title) + expect(page).to have_content(original_event.title) + # Due to caching and datatable rendering, check the count increased + expect(program.events.count).to eq original_count + 1 end end - describe 'the original event' do + describe 'deleting and updating duplicates' do before do - create(:event_schedule, - event: duplicate_event, - schedule: schedule, - room: room, - start_time: duplicate_start_time) - end - - it 'remains scheduled' do - expect(original_event.scheduled?).to be true - end - - it 'retains its original start_time' do - expect(original_schedule.start_time).to eq original_start_time - end - - it 'still has exactly one event_schedule' do - expect(original_event.event_schedules.count).to eq 1 + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + fill_in('count', with: 3) + click_button('Create Copies') + + @duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) + end + + it 'deleting a duplicate does not affect others' do + duplicate_id = @duplicates.first.id + duplicate_title = @duplicates.first.title + + visit admin_conference_program_event_path(conference.short_title, Event.find(duplicate_id)) + click_link('Delete') + page.accept_alert + + expect(Event.exists?(duplicate_id)).to be false + expect(Event.where(title: duplicate_title).count).to eq 3 # original + 2 remaining duplicates + end + + it 'can individually edit duplicates' do + duplicate = @duplicates.first + new_subtitle = 'Updated Subtitle' + + visit edit_admin_conference_program_event_path(conference.short_title, duplicate) + fill_in('event_subtitle', with: new_subtitle) + click_button('Save Event') + + duplicate.reload + expect(duplicate.subtitle).to eq new_subtitle + + # Original should not be affected + original_event.reload + expect(original_event.subtitle).not_to eq new_subtitle end end - describe 'both events scheduled independently' do - let!(:duplicate_schedule) do - create(:event_schedule, - event: duplicate_event, - schedule: schedule, - room: room, - start_time: duplicate_start_time) - end - - it 'both are scheduled' do - expect(original_event.scheduled?).to be true - expect(duplicate_event.scheduled?).to be true - end - - it 'do not share the same start_time' do - expect(original_schedule.start_time).not_to eq duplicate_schedule.start_time - end - - it 'each has exactly one event_schedule' do - expect(original_event.event_schedules.count).to eq 1 - expect(duplicate_event.event_schedules.count).to eq 1 - end - - it 'neither event_schedule is the same record' do - expect(original_schedule.id).not_to eq duplicate_schedule.id + describe 'fuzz testing - rapid operations' do + it 'handles rapid duplicate, update, delete cycles' do + expect do + 3.times do |i| + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + fill_in('count', with: 2) + click_button('Create Copies') + + new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) + + new_events.each do |event| + event.update(max_attendees: 100 + i) + end + + new_events.first.destroy + end + end.not_to raise_error + end + + it 'maintains data integrity with many duplicates' do + 5.times do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + fill_in('count', with: 2) + click_button('Create Copies') + end + + all_events = Event.where(title: original_event.title) + all_events.each do |event| + expect(event.speakers).to include(speaker) + expect(event.event_type).to eq event_type + end end end -end \ No newline at end of file +end From 503e6a057a824ce82aaad4a8dc6acca3f07806a7 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 19:38:25 -0800 Subject: [PATCH 04/26] Update Tests --- .../events_duplication_controller_spec.rb | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb index d2c65fb22..4a83da306 100644 --- a/spec/controllers/admin/events_duplication_controller_spec.rb +++ b/spec/controllers/admin/events_duplication_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Admin::EventsController, type: :controller do - let(:conference) { create(:conference, short_title: 'osem2023') } + let(:conference) { create(:conference, short_title: 'snapcon2026') } let(:program) { conference.program } let(:user) { create(:admin) } let(:event_type) { create(:event_type, program: program) } @@ -196,36 +196,37 @@ end it 'does not copy registrations' do - create(:registration) - original_event.registrations << Registration.first + registration = create(:registration) + original_event.registrations << registration + post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.where(id: Event.last.id).first + new_duplicate = Event.last expect(new_duplicate.registrations).to be_empty end it 'does not copy votes' do - create(:vote, event: original_event) + create(:vote, event: original_event, user: create(:user)) post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.where(id: Event.last.id).first + new_duplicate = Event.last expect(new_duplicate.votes).to be_empty end it 'does not copy comments' do - create(:comment, commentable: original_event) + create(:comment, commentable: original_event, user: create(:user)) post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.where(id: Event.last.id).first + new_duplicate = Event.last expect(new_duplicate.comment_threads).to be_empty end end @@ -314,13 +315,13 @@ end it 'updating original does not affect duplicates' do + original_title = original_event.title new_title = 'Updated Original Title' original_event.update(title: new_title) @duplicates.each do |duplicate| duplicate.reload - expect(duplicate.title).not_to eq new_title - expect(duplicate.title).to eq original_event.title.sub(new_title, original_event.title) + expect(duplicate.title).to eq original_title end end @@ -336,11 +337,12 @@ it 'updating a duplicate does not affect other duplicates' do first_duplicate = @duplicates.first second_duplicate = @duplicates.last + original_max = original_event.max_attendees first_duplicate.update(max_attendees: 100) second_duplicate.reload - expect(second_duplicate.max_attendees).to eq original_event.max_attendees + expect(second_duplicate.max_attendees).to eq original_max end end end From dd4ec047d4b3f599a41e80f57e37aa6a2e06fcec Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 19:44:51 -0800 Subject: [PATCH 05/26] Creating only one copy sends to Events list --- app/controllers/admin/events_controller.rb | 6 ++++-- .../controllers/admin/events_duplication_controller_spec.rb | 4 ++-- spec/features/event_duplication_spec.rb | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index d2482f55d..727feca3b 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -189,13 +189,15 @@ def duplicate duplicator = EventDuplicator.new(@event, current_user) duplicated_events = duplicator.duplicate(count) + # always send user back to the events list after duplicating, + # even when only a single copy was made. this matches the + # behaviour expected by the UX and simplifies the controller. if duplicated_events.length == 1 flash[:notice] = "Event '#{duplicated_events.first.title}' duplicated successfully." - redirect_to admin_conference_program_event_path(@conference.short_title, duplicated_events.first) else flash[:notice] = "#{duplicated_events.length} copies of '#{@event.title}' created successfully." - redirect_to admin_conference_program_events_path(@conference.short_title) end + redirect_to admin_conference_program_events_path(@conference.short_title) rescue StandardError => e flash[:alert] = "Could not duplicate event: #{e.message}" redirect_to admin_conference_program_event_path(@conference.short_title, @event) diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb index 4a83da306..fcfe5b026 100644 --- a/spec/controllers/admin/events_duplication_controller_spec.rb +++ b/spec/controllers/admin/events_duplication_controller_spec.rb @@ -46,13 +46,13 @@ end.to change(Event, :count).by(1) end - it 'redirects to the new event page' do + it 'redirects to the events index' do post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - expect(response).to redirect_to(admin_conference_program_event_path(conference.short_title, Event.last)) + expect(response).to redirect_to(admin_conference_program_events_path(conference.short_title)) end it 'sets a success flash message' do diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index c82b60113..739f82f64 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -47,16 +47,17 @@ expect(page).to have_field('count') end - it 'creates one copy by default' do + it 'creates one copy by default and returns to the events list' do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') expect(page).to have_content('duplicated successfully') expect(Event.where(title: original_event.title).count).to eq 2 + expect(page).to have_current_path(admin_conference_program_events_path(conference.short_title)) end - it 'creates multiple copies when specified' do + it 'creates multiple copies when specified and returns to the events list' do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') @@ -65,6 +66,7 @@ expect(page).to have_content('5 copies') expect(Event.where(title: original_event.title).count).to eq 6 + expect(page).to have_current_path(admin_conference_program_events_path(conference.short_title)) end it 'sets the current user as submitter of duplicates' do From c4782e84482a2d14fc81b0ef2a9b7994ce40ecda Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 20:17:33 -0800 Subject: [PATCH 06/26] Duplicatation fails if not specified between 1 and 100 events --- app/controllers/admin/events_controller.rb | 16 +++-- .../events_duplication_controller_spec.rb | 66 +++++++++++++------ spec/features/event_duplication_spec.rb | 12 ++-- spec/support/database_cleaner.rb | 1 + 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 727feca3b..3a338c671 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -183,21 +183,25 @@ def toggle_attendance end def duplicate - count = (params[:count] || 1).to_i - count = 1 if count < 1 || count > 100 # Limit to reasonable number + count = params[:count].to_i # Invalid input will be treated as 0, which will be caught by validation below + + # Validate count + unless count.between?(1, 100) + flash[:alert] = 'Invalid number of duplicates. Please enter a number between 1 and 100.' + redirect_to admin_conference_program_event_path(@conference.short_title, @event) + return + end duplicator = EventDuplicator.new(@event, current_user) duplicated_events = duplicator.duplicate(count) - # always send user back to the events list after duplicating, - # even when only a single copy was made. this matches the - # behaviour expected by the UX and simplifies the controller. if duplicated_events.length == 1 flash[:notice] = "Event '#{duplicated_events.first.title}' duplicated successfully." + redirect_to admin_conference_program_event_path(@conference.short_title, duplicated_events.first) else flash[:notice] = "#{duplicated_events.length} copies of '#{@event.title}' created successfully." + redirect_to admin_conference_program_events_path(@conference.short_title) end - redirect_to admin_conference_program_events_path(@conference.short_title) rescue StandardError => e flash[:alert] = "Could not duplicate event: #{e.message}" redirect_to admin_conference_program_event_path(@conference.short_title, @event) diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb index fcfe5b026..2f364df59 100644 --- a/spec/controllers/admin/events_duplication_controller_spec.rb +++ b/spec/controllers/admin/events_duplication_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Admin::EventsController, type: :controller do - let(:conference) { create(:conference, short_title: 'snapcon2026') } + let(:conference) { create(:conference, short_title: 'osem2023') } let(:program) { conference.program } let(:user) { create(:admin) } let(:event_type) { create(:event_type, program: program) } @@ -46,13 +46,13 @@ end.to change(Event, :count).by(1) end - it 'redirects to the events index' do + it 'redirects to the new event page' do post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - expect(response).to redirect_to(admin_conference_program_events_path(conference.short_title)) + expect(response).to redirect_to(admin_conference_program_event_path(conference.short_title, Event.last)) end it 'sets a success flash message' do @@ -106,24 +106,52 @@ end context 'with invalid count' do - it 'defaults to 1 if count is 0' do + it 'shows error if count is 0' do expect do post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 0 } - end.to change(Event, :count).by(1) + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') end - it 'caps at 100 if count is too high' do + it 'shows error if count is too high' do expect do post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 200 } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'shows error if count is negative' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: -5 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'accepts valid count at upper boundary (100)' do + expect do + post :duplicate, params: { + conference_id: conference.short_title, + id: original_event.id, + count: 100 + } end.to change(Event, :count).by(100) + + expect(flash[:notice]).to include('100 copies') end it 'defaults to 1 if count is missing' do @@ -132,7 +160,9 @@ conference_id: conference.short_title, id: original_event.id } - end.to change(Event, :count).by(1) + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') end end @@ -196,37 +226,36 @@ end it 'does not copy registrations' do - registration = create(:registration) - original_event.registrations << registration - + create(:registration) + original_event.registrations << Registration.first post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.last + new_duplicate = Event.where(id: Event.last.id).first expect(new_duplicate.registrations).to be_empty end it 'does not copy votes' do - create(:vote, event: original_event, user: create(:user)) + create(:vote, event: original_event) post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.last + new_duplicate = Event.where(id: Event.last.id).first expect(new_duplicate.votes).to be_empty end it 'does not copy comments' do - create(:comment, commentable: original_event, user: create(:user)) + create(:comment, commentable: original_event) post :duplicate, params: { conference_id: conference.short_title, id: original_event.id, count: 1 } - new_duplicate = Event.last + new_duplicate = Event.where(id: Event.last.id).first expect(new_duplicate.comment_threads).to be_empty end end @@ -315,13 +344,13 @@ end it 'updating original does not affect duplicates' do - original_title = original_event.title new_title = 'Updated Original Title' original_event.update(title: new_title) @duplicates.each do |duplicate| duplicate.reload - expect(duplicate.title).to eq original_title + expect(duplicate.title).not_to eq new_title + expect(duplicate.title).to eq original_event.title.sub(new_title, original_event.title) end end @@ -337,12 +366,11 @@ it 'updating a duplicate does not affect other duplicates' do first_duplicate = @duplicates.first second_duplicate = @duplicates.last - original_max = original_event.max_attendees first_duplicate.update(max_attendees: 100) second_duplicate.reload - expect(second_duplicate.max_attendees).to eq original_max + expect(second_duplicate.max_attendees).to eq original_event.max_attendees end end end diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 739f82f64..275b87624 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -5,7 +5,7 @@ describe 'Event Duplication Feature', :js do let(:conference) { create(:full_conference, short_title: 'osem2023') } let(:program) { conference.program } - let(:admin_user) { create(:admin, email: 'admin@osem.io', password: 'password123') } + let(:user) { create(:admin) } let(:event_type) { create(:event_type, program: program) } let(:track) { create(:track, state: 'confirmed', program: program) } let(:difficulty_level) { create(:difficulty_level, program: program) } @@ -31,7 +31,7 @@ end before do - login_as admin_user, scope: :user + sign_in user end describe 'duplicating an event via web interface' do @@ -47,17 +47,16 @@ expect(page).to have_field('count') end - it 'creates one copy by default and returns to the events list' do + it 'creates one copy by default' do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') expect(page).to have_content('duplicated successfully') expect(Event.where(title: original_event.title).count).to eq 2 - expect(page).to have_current_path(admin_conference_program_events_path(conference.short_title)) end - it 'creates multiple copies when specified and returns to the events list' do + it 'creates multiple copies when specified' do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') @@ -66,7 +65,6 @@ expect(page).to have_content('5 copies') expect(Event.where(title: original_event.title).count).to eq 6 - expect(page).to have_current_path(admin_conference_program_events_path(conference.short_title)) end it 'sets the current user as submitter of duplicates' do @@ -76,7 +74,7 @@ duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) duplicates.each do |dup| - expect(dup.submitter).to eq admin_user + expect(dup.submitter).to eq user end end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 722e270d4..524bcf0cc 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'database_cleaner/active_record' rescue require 'database_cleaner' RSpec.configure do |config| config.before(:suite) do DatabaseCleaner.clean_with(:truncation) From 916b1a2a51f256140047d9480f67cb6a8c495510 Mon Sep 17 00:00:00 2001 From: Yijun Zhou Date: Thu, 5 Mar 2026 21:22:58 -0800 Subject: [PATCH 07/26] Add conference duplication feature (Iteration 2) --- .../admin/conferences_controller.rb | 45 +++++++++- app/models/conference.rb | 82 +++++++++++++++++++ app/views/admin/conferences/new.html.haml | 6 ++ app/views/admin/conferences/show.html.haml | 1 + 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/conferences_controller.rb b/app/controllers/admin/conferences_controller.rb index 17d2bc131..1a46d3044 100644 --- a/app/controllers/admin/conferences_controller.rb +++ b/app/controllers/admin/conferences_controller.rb @@ -67,19 +67,62 @@ def index end def new - @conference = Conference.new + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference = Conference.new( + description: source.description, + timezone: source.timezone, + start_hour: source.start_hour, + end_hour: source.end_hour, + color: source.color, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + organization_id: source.organization_id + ) + @duplicate_from_source = source.short_title + else + @conference = Conference.new + end + else + @conference = Conference.new + end end def create @conference = Conference.new(conference_params) + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference.assign_attributes( + description: source.description, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + color: source.color, + start_hour: source.start_hour, + end_hour: source.end_hour + ) + end + end + if @conference.save # user that creates the conference becomes organizer of that conference current_user.add_role :organizer, @conference + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + @conference.copy_associations_from(source) if source && can?(:read, source) + end + redirect_to admin_conference_path(id: @conference.short_title), notice: 'Conference was successfully created.' else + @duplicate_from_source = params[:duplicate_from] flash.now[:error] = 'Could not create conference. ' + @conference.errors.full_messages.to_sentence render action: 'new' end diff --git a/app/models/conference.rb b/app/models/conference.rb index 585420049..93ca6e04f 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -841,6 +841,88 @@ def ended? end_date < Time.current end + ## + # Copies associations from another conference (for duplication). + # Includes: registration_period, email_settings, venue+rooms, tickets, + # event_types, tracks, difficulty_levels, sponsorship_levels, sponsors. + # Excludes: events, registrations, and other user/attendee data. + # + def copy_associations_from(source) + return unless source && source != self + + # Registration period (clamp to new conference dates) + if source.registration_period.present? + rp = source.registration_period + start_d = [rp.start_date, start_date].max + end_d = [rp.end_date, end_date].min + start_d = end_d = start_date if start_d > end_d + create_registration_period!(start_date: start_d, end_date: end_d) + end + + # Email settings (conference already has one from create_email_settings) + if source.email_settings.present? && email_settings.present? + attrs = source.email_settings.attributes.except('id', 'conference_id', 'created_at', 'updated_at') + email_settings.update!(attrs) + end + + # Venue and rooms (map old room id -> new room for tracks later) + room_id_map = {} + if source.venue.present? + new_venue = create_venue!( + source.venue.attributes.slice('name', 'street', 'city', 'country', 'description', 'postalcode', 'website', 'latitude', 'longitude') + ) + source.venue.rooms.order(:id).each_with_index do |old_room, _idx| + new_room = new_venue.rooms.create!( + old_room.attributes.slice('name', 'size', 'order').merge(guid: SecureRandom.urlsafe_base64) + ) + room_id_map[old_room.id] = new_room.id + end + end + + # Tickets (conference already has one free ticket from create_free_ticket; skip source's free to avoid duplicate) + source.tickets.each do |t| + next if t.title == 'Free Access' && t.price_cents.zero? + + tickets.create!( + t.attributes.slice('title', 'description', 'price_cents', 'price_currency', 'registration_ticket', 'visible', 'email_subject', 'email_body') + ) + end + + # Event types and difficulty levels (program exists from after_create) + source.program&.event_types&.each do |et| + program.event_types.create!( + et.attributes.slice('title', 'length', 'color', 'description', 'minimum_abstract_length', 'maximum_abstract_length', 'submission_template', 'enable_public_submission') + ) + end + source.program&.difficulty_levels&.each do |dl| + program.difficulty_levels.create!( + dl.attributes.slice('title', 'description', 'color') + ) + end + + # Tracks (assign new room by same index, or nil if no room) + source.program&.tracks&.each do |t| + old_room_id = t.room_id + new_room_id = old_room_id ? room_id_map[old_room_id] : nil + program.tracks.create!( + t.attributes.slice('name', 'short_name', 'description', 'color', 'state', 'relevance', 'start_date', 'end_date', 'cfp_active').merge( + guid: SecureRandom.urlsafe_base64, + room_id: new_room_id + ) + ) + end + + # Sponsorship levels and sponsors + source.sponsorship_levels.each do |sl| + new_sl = sponsorship_levels.create!(sl.attributes.slice('title', 'position')) + sl.sponsors.each do |sp| + new_sl.sponsors.create!( + sp.attributes.slice('name', 'description', 'website_url') + ) + end + end + end + private # Returns a different html colour for every i and consecutive colors are diff --git a/app/views/admin/conferences/new.html.haml b/app/views/admin/conferences/new.html.haml index 2d2ea53e2..c2059562b 100644 --- a/app/views/admin/conferences/new.html.haml +++ b/app/views/admin/conferences/new.html.haml @@ -1,4 +1,10 @@ +- if @duplicate_from_source + .row + .col-md-8 + .alert.alert-info + Duplicating from an existing conference. Please set Title, Short title, and Dates for the new conference. .row .col-md-8 = form_for(@conference, url: admin_conferences_path) do |f| + = hidden_field_tag :duplicate_from, @duplicate_from_source if @duplicate_from_source = render partial: 'form_fields', locals: { f: f } diff --git a/app/views/admin/conferences/show.html.haml b/app/views/admin/conferences/show.html.haml index 8d164643f..11821a2e6 100644 --- a/app/views/admin/conferences/show.html.haml +++ b/app/views/admin/conferences/show.html.haml @@ -1,6 +1,7 @@ %h1 %span.fa-solid.fa-gauge-high Dashboard for #{@conference.title} + = link_to 'Duplicate', new_admin_conference_path(duplicate_from: @conference.short_title), class: 'btn btn-primary btn-sm', style: 'margin-left: 12px;' %hr .row .col-sm-3.col-xs-6 From 34ff0c3ed0c7b505abf53a56a34df2deff12588d Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 22:44:52 -0800 Subject: [PATCH 08/26] Update test suite to fix latency failing tests --- app/services/event_duplicator.rb | 1 + .../admin/events_duplication_controller_spec.rb | 13 +++++++++---- spec/features/event_duplication_spec.rb | 10 ++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb index d5b938b03..37f9b6d97 100644 --- a/app/services/event_duplicator.rb +++ b/app/services/event_duplicator.rb @@ -44,6 +44,7 @@ def create_duplicate progress: @event.progress, public: @event.public, is_highlight: @event.is_highlight, + superevent: @event.superevent, # Don't copy: start_time, room_id, parent_id (reset to nil) start_time: nil, diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb index 2f364df59..5a4a4f48f 100644 --- a/spec/controllers/admin/events_duplication_controller_spec.rb +++ b/spec/controllers/admin/events_duplication_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Admin::EventsController, type: :controller do - let(:conference) { create(:conference, short_title: 'osem2023') } + let(:conference) { create(:conference, short_title: 'snapcon2026') } let(:program) { conference.program } let(:user) { create(:admin) } let(:event_type) { create(:event_type, program: program) } @@ -13,7 +13,7 @@ let(:speaker) { create(:user) } let(:volunteer) { create(:user) } - let(:original_event) do + let!(:original_event) do event = create(:event, program: program, title: 'Original Event', @@ -154,7 +154,7 @@ expect(flash[:notice]).to include('100 copies') end - it 'defaults to 1 if count is missing' do + it 'shows error if count is missing' do expect do post :duplicate, params: { conference_id: conference.short_title, @@ -204,6 +204,10 @@ expect(@duplicate.public).to eq original_event.public end + it 'copies superevent status' do + expect(@duplicate.superevent).to eq original_event.superevent + end + it 'sets new event state' do expect(@duplicate.state).to eq 'new' end @@ -344,13 +348,14 @@ end it 'updating original does not affect duplicates' do + original_title = original_event.title new_title = 'Updated Original Title' original_event.update(title: new_title) @duplicates.each do |duplicate| duplicate.reload expect(duplicate.title).not_to eq new_title - expect(duplicate.title).to eq original_event.title.sub(new_title, original_event.title) + expect(duplicate.title).to eq original_title end end diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 275b87624..a541cc3a3 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -14,7 +14,7 @@ let(:venue) { conference.venue || create(:venue, conference: conference) } let(:room) { create(:room, venue: venue) } - let(:original_event) do + let!(:original_event) do event = create(:event, program: program, title: 'Test Event for Duplication', @@ -96,6 +96,7 @@ end it 'shows the duplicate in the events list' do + original_event original_count = program.events.count visit admin_conference_program_event_path(conference.short_title, original_event) @@ -124,8 +125,9 @@ duplicate_title = @duplicates.first.title visit admin_conference_program_event_path(conference.short_title, Event.find(duplicate_id)) - click_link('Delete') - page.accept_alert + accept_confirm do + click_link('Delete') + end expect(Event.exists?(duplicate_id)).to be false expect(Event.where(title: duplicate_title).count).to eq 3 # original + 2 remaining duplicates @@ -137,7 +139,7 @@ visit edit_admin_conference_program_event_path(conference.short_title, duplicate) fill_in('event_subtitle', with: new_subtitle) - click_button('Save Event') + click_button('Update Event') duplicate.reload expect(duplicate.subtitle).to eq new_subtitle From 08619ba69a8aee9aabd6578056955c68eb7dc6ee Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 22:59:39 -0800 Subject: [PATCH 09/26] Fix some tests --- spec/features/event_duplication_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index a541cc3a3..12f04eb8c 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'Event Duplication Feature', :js do - let(:conference) { create(:full_conference, short_title: 'osem2023') } + let(:conference) { create(:full_conference, short_title: 'snapcon2026') } let(:program) { conference.program } let(:user) { create(:admin) } let(:event_type) { create(:event_type, program: program) } @@ -126,7 +126,7 @@ visit admin_conference_program_event_path(conference.short_title, Event.find(duplicate_id)) accept_confirm do - click_link('Delete') + find("a[href='#{admin_conference_program_event_path(conference.short_title, duplicate_id)}'][data-method='delete']").click end expect(Event.exists?(duplicate_id)).to be false @@ -139,7 +139,7 @@ visit edit_admin_conference_program_event_path(conference.short_title, duplicate) fill_in('event_subtitle', with: new_subtitle) - click_button('Update Event') + click_button('Update Proposal') duplicate.reload expect(duplicate.subtitle).to eq new_subtitle From bf0b24591a59d8c88148cc862d0f1270a0a91dd4 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 23:30:15 -0800 Subject: [PATCH 10/26] Add timeout for race condition test --- spec/features/event_duplication_spec.rb | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 12f04eb8c..207deccf1 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -31,7 +31,7 @@ end before do - sign_in user + login_as user, scope: :user end describe 'duplicating an event via web interface' do @@ -83,7 +83,15 @@ click_button('Duplicate') click_button('Create Copies') - duplicate = Event.where(title: original_event.title).where.not(id: original_event.id).first + # Wait for duplicate to be queryable (database transaction visibility) + duplicate = nil + Timeout.timeout(5) do + loop do + duplicate = Event.where(title: original_event.title).where.not(id: original_event.id).first + break if duplicate + sleep 0.1 + end + end expect(duplicate.abstract).to eq original_event.abstract expect(duplicate.description).to eq original_event.description @@ -123,12 +131,13 @@ it 'deleting a duplicate does not affect others' do duplicate_id = @duplicates.first.id duplicate_title = @duplicates.first.title - + visit admin_conference_program_event_path(conference.short_title, Event.find(duplicate_id)) accept_confirm do - find("a[href='#{admin_conference_program_event_path(conference.short_title, duplicate_id)}'][data-method='delete']").click + click_link('Delete', match: :first) end - + + expect(page).to have_content('Event successfully deleted.') expect(Event.exists?(duplicate_id)).to be false expect(Event.where(title: duplicate_title).count).to eq 3 # original + 2 remaining duplicates end @@ -159,13 +168,21 @@ fill_in('count', with: 2) click_button('Create Copies') - new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) + # Wait for duplicates to be queryable (database transaction visibility) + new_events = nil + Timeout.timeout(5) do + loop do + new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) + break if new_events&.all?(&:present?) + sleep 0.1 + end + end new_events.each do |event| event.update(max_attendees: 100 + i) end - new_events.first.destroy + new_events.first&.destroy end end.not_to raise_error end From f4d05b41e3b2322c34e60383e70b7ba0efc30e78 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Thu, 5 Mar 2026 23:52:00 -0800 Subject: [PATCH 11/26] Add timeouts to the rest of the tests --- spec/features/event_duplication_spec.rb | 59 ++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 207deccf1..168d3cfff 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -53,6 +53,15 @@ click_button('Create Copies') expect(page).to have_content('duplicated successfully') + + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 2 + sleep 0.1 + end + end expect(Event.where(title: original_event.title).count).to eq 2 end @@ -64,6 +73,15 @@ click_button('Create Copies') expect(page).to have_content('5 copies') + + # Wait for duplicates to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 6 + sleep 0.1 + end + end expect(Event.where(title: original_event.title).count).to eq 6 end @@ -72,6 +90,15 @@ click_button('Duplicate') click_button('Create Copies') + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) + break if duplicates.count >= 1 + sleep 0.1 + end + end + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) duplicates.each do |dup| expect(dup.submitter).to eq user @@ -111,6 +138,15 @@ click_button('Duplicate') click_button('Create Copies') + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = program.events.count + break if count > original_count + sleep 0.1 + end + end + visit admin_conference_program_events_path(conference.short_title) expect(page).to have_content(original_event.title) # Due to caching and datatable rendering, check the count increased @@ -125,6 +161,16 @@ fill_in('count', with: 3) click_button('Create Copies') + # Wait for duplicates to be queryable (database transaction visibility) + # We expect 1 original + 3 copies = 4 total events + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 4 + sleep 0.1 + end + end + @duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) end @@ -188,11 +234,22 @@ end it 'maintains data integrity with many duplicates' do - 5.times do + 5.times do |iteration| visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') fill_in('count', with: 2) click_button('Create Copies') + + # Wait for duplicates to be queryable (database transaction visibility) + # This ensures the POST request completes before visiting the page again + expected_count = 1 + 2 * (iteration + 1) + Timeout.timeout(5) do + loop do + actual_count = Event.where(title: original_event.title).count + break if actual_count >= expected_count + sleep 0.1 + end + end end all_events = Event.where(title: original_event.title) From 0694aac27518a997fcce8c510f67d993fdd78de3 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 01:02:56 -0800 Subject: [PATCH 12/26] Rubocop, more test fixes --- app/controllers/admin/events_controller.rb | 9 +- app/services/event_duplicator.rb | 60 ++- .../admin/events_controller_spec.rb | 390 +++++++++++++++++- .../events_duplication_controller_spec.rb | 382 ----------------- spec/features/event_duplication_spec.rb | 77 ++-- spec/support/database_cleaner.rb | 7 +- 6 files changed, 466 insertions(+), 459 deletions(-) delete mode 100644 spec/controllers/admin/events_duplication_controller_spec.rb diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 3a338c671..3ed146121 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -184,24 +184,23 @@ def toggle_attendance def duplicate count = params[:count].to_i # Invalid input will be treated as 0, which will be caught by validation below - + # Validate count unless count.between?(1, 100) flash[:alert] = 'Invalid number of duplicates. Please enter a number between 1 and 100.' redirect_to admin_conference_program_event_path(@conference.short_title, @event) return end - + duplicator = EventDuplicator.new(@event, current_user) duplicated_events = duplicator.duplicate(count) - + if duplicated_events.length == 1 flash[:notice] = "Event '#{duplicated_events.first.title}' duplicated successfully." - redirect_to admin_conference_program_event_path(@conference.short_title, duplicated_events.first) else flash[:notice] = "#{duplicated_events.length} copies of '#{@event.title}' created successfully." - redirect_to admin_conference_program_events_path(@conference.short_title) end + redirect_to admin_conference_program_events_path(@conference.short_title) rescue StandardError => e flash[:alert] = "Could not duplicate event: #{e.message}" redirect_to admin_conference_program_event_path(@conference.short_title, @event) diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb index 37f9b6d97..2a80da7ee 100644 --- a/app/services/event_duplicator.rb +++ b/app/services/event_duplicator.rb @@ -19,47 +19,41 @@ def duplicate(count = 1) def create_duplicate duplicate_event = Event.create!( # Content fields - title: @event.title, - subtitle: @event.subtitle, - abstract: @event.abstract, - description: @event.description, - submission_text: @event.submission_text, - committee_review: @event.committee_review, + title: @event.title, + subtitle: @event.subtitle, + abstract: @event.abstract, + description: @event.description, + submission_text: @event.submission_text, + committee_review: @event.committee_review, proposal_additional_speakers: @event.proposal_additional_speakers, - - # Configuration fields - event_type: @event.event_type, - track: @event.track, - difficulty_level: @event.difficulty_level, - language: @event.language, - presentation_mode: @event.presentation_mode, - - # Registration and attendance - require_registration: @event.require_registration, - max_attendees: @event.max_attendees, - - # Status fields - program: @event.program, - state: 'new', - progress: @event.progress, - public: @event.public, - is_highlight: @event.is_highlight, - superevent: @event.superevent, - + event_type: @event.event_type, + track: @event.track, + difficulty_level: @event.difficulty_level, + language: @event.language, + presentation_mode: @event.presentation_mode, + require_registration: @event.require_registration, + max_attendees: @event.max_attendees, + program: @event.program, + state: 'new', + progress: @event.progress, + public: @event.public, + is_highlight: @event.is_highlight, + superevent: @event.superevent, + # Don't copy: start_time, room_id, parent_id (reset to nil) - start_time: nil, - room_id: nil, - parent_id: nil, - + start_time: nil, + room_id: nil, + parent_id: nil, + # Submitter - submitter: @submitter + submitter: @submitter ) - + # Copy speakers and volunteers duplicate_event.speakers = @event.speakers duplicate_event.volunteers = @event.volunteers duplicate_event.save! - + duplicate_event end end diff --git a/spec/controllers/admin/events_controller_spec.rb b/spec/controllers/admin/events_controller_spec.rb index 19b8048ab..fa328d500 100644 --- a/spec/controllers/admin/events_controller_spec.rb +++ b/spec/controllers/admin/events_controller_spec.rb @@ -9,9 +9,9 @@ # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 # (the numbers could be different as long as there is this matching of IDs). # We implemented or own where method to solve this and those ids are for testing this case. - let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } - let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } - let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } + let!(:event_without_commercial) { create(:event, program: conference.program) } + let!(:event_with_commercial) { create(:event, program: conference.program) } + let!(:event_commercial) { create(:event_commercial, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } with_versioning do describe 'GET #show' do @@ -27,4 +27,388 @@ end end end + + describe '#duplicate' do + let(:dup_conference) { create(:conference, short_title: 'snapcon2026') } + let(:program) { dup_conference.program } + let(:user) { create(:admin) } + let(:event_type) { create(:event_type, program: program) } + let(:track) { create(:track, state: 'confirmed', program: program) } + let(:difficulty_level) { create(:difficulty_level, program: program) } + let(:speaker) { create(:user) } + let(:volunteer) { create(:user) } + + let!(:original_event) do + event = create(:event, + program: program, + title: 'Original Event', + abstract: 'An abstract', + description: 'A description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, + require_registration: true, + max_attendees: 50, + state: 'confirmed') + event.speakers << speaker + event.volunteers << volunteer + event + end + + before do + sign_in user + end + + context 'with single duplicate' do + it 'creates one copy of the event' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'redirects to the new event page' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + expect(response).to redirect_to(admin_conference_program_event_path(dup_conference.short_title, Event.last)) + end + + it 'sets a success flash message' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + expect(flash[:notice]).to include('duplicated successfully') + end + + it 'assigns the current user as submitter' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_event = Event.last + expect(new_event.submitter).to eq user + end + end + + context 'with multiple duplicates' do + it 'creates the requested number of copies' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + end.to change(Event, :count).by(3) + end + + it 'redirects to the events index when creating multiple' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + expect(response).to redirect_to(admin_conference_program_events_path(dup_conference.short_title)) + end + + it 'shows the count of created copies in flash message' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + expect(flash[:notice]).to include('3 copies') + end + end + + context 'with invalid count' do + it 'shows error if count is 0' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 0 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'shows error if count is too high' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 200 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'shows error if count is negative' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: -5 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'accepts valid count at upper boundary (100)' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 100 + } + end.to change(Event, :count).by(100) + + expect(flash[:notice]).to include('100 copies') + end + + it 'shows error if count is missing' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + end + + context 'field copying' do + let(:duplicate) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + Event.last + end + + it 'copies content fields' do + expect(duplicate.title).to eq original_event.title + expect(duplicate.abstract).to eq original_event.abstract + expect(duplicate.description).to eq original_event.description + expect(duplicate.subtitle).to eq original_event.subtitle + end + + it 'copies configuration fields' do + expect(duplicate.event_type).to eq original_event.event_type + expect(duplicate.track).to eq original_event.track + expect(duplicate.difficulty_level).to eq original_event.difficulty_level + expect(duplicate.language).to eq original_event.language + end + + it 'copies registration settings' do + expect(duplicate.require_registration).to eq original_event.require_registration + expect(duplicate.max_attendees).to eq original_event.max_attendees + end + + it 'copies speakers and volunteers' do + expect(duplicate.speakers).to include(speaker) + expect(duplicate.volunteers).to include(volunteer) + end + + it 'copies public status' do + expect(duplicate.public).to eq original_event.public + end + + it 'copies superevent status' do + expect(duplicate.superevent).to eq original_event.superevent + end + + it 'sets new event state' do + expect(duplicate.state).to eq 'new' + end + + it 'generates new guid' do + expect(duplicate.guid).not_to eq original_event.guid + expect(duplicate.guid).to be_present + end + + it 'does not copy start_time' do + expect(duplicate.start_time).to be_nil + end + + it 'does not copy room' do + expect(duplicate.room_id).to be_nil + end + + it 'does not have parent' do + expect(duplicate.parent_id).to be_nil + end + + it 'does not copy registrations' do + create(:registration) + original_event.registrations << Registration.first + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.registrations).to be_empty + end + + it 'does not copy votes' do + create(:vote, event: original_event) + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.votes).to be_empty + end + + it 'does not copy comments' do + create(:comment, commentable: original_event) + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.comment_threads).to be_empty + end + end + + context 'duplicate of duplicate' do + let(:first_duplicate) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + Event.last + end + + it 'can duplicate a duplicate' do + # Evaluate first_duplicate before the expect block to avoid counting its creation + dup_id = first_duplicate.id + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: dup_id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'preserves all fields when duplicating a duplicate' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: first_duplicate.id, + count: 1 + } + second_duplicate = Event.last + + expect(second_duplicate.title).to eq original_event.title + expect(second_duplicate.speakers).to include(speaker) + expect(second_duplicate.volunteers).to include(volunteer) + expect(second_duplicate.difficulty_level).to eq original_event.difficulty_level + end + end + + context 'deletion independence' do + let(:duplicates) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + Event.where(title: original_event.title).order(:created_at).last(3) + end + + it 'deleting a duplicate does not delete other duplicates' do + dup_to_delete = duplicates.first + second_dup_id = duplicates[1].id + third_dup_id = duplicates[2].id + + expect do + delete :destroy, params: { + conference_id: dup_conference.short_title, + id: dup_to_delete.id + } + end.to change(Event, :count).by(-1) + + expect(Event.exists?(second_dup_id)).to be true + expect(Event.exists?(third_dup_id)).to be true + end + + it 'deleting the original does not delete duplicates' do + dups_ids = duplicates.map(&:id) + + expect do + delete :destroy, params: { + conference_id: dup_conference.short_title, + id: original_event.id + } + end.to change(Event, :count).by(-1) + + dups_ids.each do |dup_id| + expect(Event.exists?(dup_id)).to be true + end + end + end + + context 'update independence' do + let(:duplicates) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 2 + } + Event.where(title: original_event.title).order(:created_at).last(2) + end + + it 'updating original does not affect duplicates' do + # Create duplicates first to capture original title + dups = duplicates + original_title = original_event.title + new_title = 'Updated Original Title' + original_event.update(title: new_title) + + dups.each do |dup| + dup.reload + expect(dup.title).not_to eq new_title + expect(dup.title).to eq original_title + end + end + + it 'updating a duplicate does not affect original' do + dup = duplicates.first + new_title = 'Updated Duplicate Title' + dup.update(title: new_title) + + original_event.reload + expect(original_event.title).not_to eq new_title + end + + it 'updating a duplicate does not affect other duplicates' do + first_dup = duplicates.first + second_dup = duplicates.last + + first_dup.update(max_attendees: 100) + + second_dup.reload + expect(second_dup.max_attendees).to eq original_event.max_attendees + end + end + end end diff --git a/spec/controllers/admin/events_duplication_controller_spec.rb b/spec/controllers/admin/events_duplication_controller_spec.rb deleted file mode 100644 index 5a4a4f48f..000000000 --- a/spec/controllers/admin/events_duplication_controller_spec.rb +++ /dev/null @@ -1,382 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Admin::EventsController, type: :controller do - let(:conference) { create(:conference, short_title: 'snapcon2026') } - let(:program) { conference.program } - let(:user) { create(:admin) } - let(:event_type) { create(:event_type, program: program) } - let(:track) { create(:track, state: 'confirmed', program: program) } - let(:difficulty_level) { create(:difficulty_level, program: program) } - - let(:speaker) { create(:user) } - let(:volunteer) { create(:user) } - - let!(:original_event) do - event = create(:event, - program: program, - title: 'Original Event', - abstract: 'An abstract', - description: 'A description', - event_type: event_type, - track: track, - difficulty_level: difficulty_level, - require_registration: true, - max_attendees: 50, - state: 'confirmed') - event.speakers << speaker - event.volunteers << volunteer - event - end - - before do - sign_in user - end - - describe '#duplicate' do - context 'with single duplicate' do - it 'creates one copy of the event' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - end.to change(Event, :count).by(1) - end - - it 'redirects to the new event page' do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - expect(response).to redirect_to(admin_conference_program_event_path(conference.short_title, Event.last)) - end - - it 'sets a success flash message' do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - expect(flash[:notice]).to include('duplicated successfully') - end - - it 'assigns the current user as submitter' do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - new_event = Event.last - expect(new_event.submitter).to eq user - end - end - - context 'with multiple duplicates' do - it 'creates the requested number of copies' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 3 - } - end.to change(Event, :count).by(3) - end - - it 'redirects to the events index when creating multiple' do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 3 - } - expect(response).to redirect_to(admin_conference_program_events_path(conference.short_title)) - end - - it 'shows the count of created copies in flash message' do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 3 - } - expect(flash[:notice]).to include('3 copies') - end - end - - context 'with invalid count' do - it 'shows error if count is 0' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 0 - } - end.not_to change(Event, :count) - - expect(flash[:alert]).to include('Invalid number of duplicates') - end - - it 'shows error if count is too high' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 200 - } - end.not_to change(Event, :count) - - expect(flash[:alert]).to include('Invalid number of duplicates') - end - - it 'shows error if count is negative' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: -5 - } - end.not_to change(Event, :count) - - expect(flash[:alert]).to include('Invalid number of duplicates') - end - - it 'accepts valid count at upper boundary (100)' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 100 - } - end.to change(Event, :count).by(100) - - expect(flash[:notice]).to include('100 copies') - end - - it 'shows error if count is missing' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id - } - end.not_to change(Event, :count) - - expect(flash[:alert]).to include('Invalid number of duplicates') - end - end - - context 'field copying' do - before do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - @duplicate = Event.last - end - - it 'copies content fields' do - expect(@duplicate.title).to eq original_event.title - expect(@duplicate.abstract).to eq original_event.abstract - expect(@duplicate.description).to eq original_event.description - expect(@duplicate.subtitle).to eq original_event.subtitle - end - - it 'copies configuration fields' do - expect(@duplicate.event_type).to eq original_event.event_type - expect(@duplicate.track).to eq original_event.track - expect(@duplicate.difficulty_level).to eq original_event.difficulty_level - expect(@duplicate.language).to eq original_event.language - end - - it 'copies registration settings' do - expect(@duplicate.require_registration).to eq original_event.require_registration - expect(@duplicate.max_attendees).to eq original_event.max_attendees - end - - it 'copies speakers and volunteers' do - expect(@duplicate.speakers).to include(speaker) - expect(@duplicate.volunteers).to include(volunteer) - end - - it 'copies public status' do - expect(@duplicate.public).to eq original_event.public - end - - it 'copies superevent status' do - expect(@duplicate.superevent).to eq original_event.superevent - end - - it 'sets new event state' do - expect(@duplicate.state).to eq 'new' - end - - it 'generates new guid' do - expect(@duplicate.guid).not_to eq original_event.guid - expect(@duplicate.guid).to be_present - end - - it 'does not copy start_time' do - expect(@duplicate.start_time).to be_nil - end - - it 'does not copy room' do - expect(@duplicate.room_id).to be_nil - end - - it 'does not have parent' do - expect(@duplicate.parent_id).to be_nil - end - - it 'does not copy registrations' do - create(:registration) - original_event.registrations << Registration.first - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - new_duplicate = Event.where(id: Event.last.id).first - expect(new_duplicate.registrations).to be_empty - end - - it 'does not copy votes' do - create(:vote, event: original_event) - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - new_duplicate = Event.where(id: Event.last.id).first - expect(new_duplicate.votes).to be_empty - end - - it 'does not copy comments' do - create(:comment, commentable: original_event) - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - new_duplicate = Event.where(id: Event.last.id).first - expect(new_duplicate.comment_threads).to be_empty - end - end - - context 'duplicate of duplicate' do - before do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 1 - } - @first_duplicate = Event.last - end - - it 'can duplicate a duplicate' do - expect do - post :duplicate, params: { - conference_id: conference.short_title, - id: @first_duplicate.id, - count: 1 - } - end.to change(Event, :count).by(1) - end - - it 'preserves all fields when duplicating a duplicate' do - post :duplicate, params: { - conference_id: conference.short_title, - id: @first_duplicate.id, - count: 1 - } - second_duplicate = Event.last - - expect(second_duplicate.title).to eq original_event.title - expect(second_duplicate.speakers).to include(speaker) - expect(second_duplicate.volunteers).to include(volunteer) - expect(second_duplicate.difficulty_level).to eq original_event.difficulty_level - end - end - - context 'deletion independence' do - before do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 3 - } - @duplicates = Event.where(title: original_event.title).order(:created_at).last(3) - end - - it 'deleting a duplicate does not delete other duplicates' do - duplicate_to_delete = @duplicates.first - - expect do - delete :destroy, params: { - conference_id: conference.short_title, - id: duplicate_to_delete.id - } - end.to change(Event, :count).by(-1) - - expect(Event.exists?(@duplicates[1].id)).to be true - expect(Event.exists?(@duplicates[2].id)).to be true - end - - it 'deleting the original does not delete duplicates' do - expect do - delete :destroy, params: { - conference_id: conference.short_title, - id: original_event.id - } - end.to change(Event, :count).by(-1) - - @duplicates.each do |duplicate| - expect(Event.exists?(duplicate.id)).to be true - end - end - end - - context 'update independence' do - before do - post :duplicate, params: { - conference_id: conference.short_title, - id: original_event.id, - count: 2 - } - @duplicates = Event.where(title: original_event.title).order(:created_at).last(2) - end - - it 'updating original does not affect duplicates' do - original_title = original_event.title - new_title = 'Updated Original Title' - original_event.update(title: new_title) - - @duplicates.each do |duplicate| - duplicate.reload - expect(duplicate.title).not_to eq new_title - expect(duplicate.title).to eq original_title - end - end - - it 'updating a duplicate does not affect original' do - duplicate = @duplicates.first - new_title = 'Updated Duplicate Title' - duplicate.update(title: new_title) - - original_event.reload - expect(original_event.title).not_to eq new_title - end - - it 'updating a duplicate does not affect other duplicates' do - first_duplicate = @duplicates.first - second_duplicate = @duplicates.last - - first_duplicate.update(max_attendees: 100) - - second_duplicate.reload - expect(second_duplicate.max_attendees).to eq original_event.max_attendees - end - end - end -end diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 168d3cfff..9e0bd8bb4 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -16,16 +16,16 @@ let!(:original_event) do event = create(:event, - program: program, - title: 'Test Event for Duplication', - abstract: 'Event abstract', - description: 'Event description', - event_type: event_type, - track: track, - difficulty_level: difficulty_level, + program: program, + title: 'Test Event for Duplication', + abstract: 'Event abstract', + description: 'Event description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, require_registration: true, - max_attendees: 50, - state: 'confirmed') + max_attendees: 50, + state: 'confirmed') event.speakers << speaker event end @@ -51,14 +51,15 @@ visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') - + expect(page).to have_content('duplicated successfully') - + # Wait for duplicate to be queryable (database transaction visibility) Timeout.timeout(5) do loop do count = Event.where(title: original_event.title).count break if count >= 2 + sleep 0.1 end end @@ -68,17 +69,18 @@ it 'creates multiple copies when specified' do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') - + fill_in('count', with: 5) click_button('Create Copies') - + expect(page).to have_content('5 copies') - + # Wait for duplicates to be queryable (database transaction visibility) Timeout.timeout(5) do loop do count = Event.where(title: original_event.title).count break if count >= 6 + sleep 0.1 end end @@ -89,16 +91,17 @@ visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') - + # Wait for duplicate to be queryable (database transaction visibility) Timeout.timeout(5) do loop do duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) break if duplicates.count >= 1 + sleep 0.1 end end - + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) duplicates.each do |dup| expect(dup.submitter).to eq user @@ -109,17 +112,18 @@ visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') - + # Wait for duplicate to be queryable (database transaction visibility) duplicate = nil Timeout.timeout(5) do loop do duplicate = Event.where(title: original_event.title).where.not(id: original_event.id).first break if duplicate + sleep 0.1 end end - + expect(duplicate.abstract).to eq original_event.abstract expect(duplicate.description).to eq original_event.description expect(duplicate.event_type).to eq original_event.event_type @@ -133,20 +137,21 @@ it 'shows the duplicate in the events list' do original_event original_count = program.events.count - + visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') click_button('Create Copies') - + # Wait for duplicate to be queryable (database transaction visibility) Timeout.timeout(5) do loop do count = program.events.count break if count > original_count + sleep 0.1 end end - + visit admin_conference_program_events_path(conference.short_title) expect(page).to have_content(original_event.title) # Due to caching and datatable rendering, check the count increased @@ -160,32 +165,32 @@ click_button('Duplicate') fill_in('count', with: 3) click_button('Create Copies') - + # Wait for duplicates to be queryable (database transaction visibility) # We expect 1 original + 3 copies = 4 total events Timeout.timeout(5) do loop do count = Event.where(title: original_event.title).count break if count >= 4 + sleep 0.1 end end - - @duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) end it 'deleting a duplicate does not affect others' do - duplicate_id = @duplicates.first.id - duplicate_title = @duplicates.first.title + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) + dup_id = duplicates.first.id + dup_title = duplicates.first.title - visit admin_conference_program_event_path(conference.short_title, Event.find(duplicate_id)) + visit admin_conference_program_event_path(conference.short_title, Event.find(dup_id)) accept_confirm do click_link('Delete', match: :first) end expect(page).to have_content('Event successfully deleted.') - expect(Event.exists?(duplicate_id)).to be false - expect(Event.where(title: duplicate_title).count).to eq 3 # original + 2 remaining duplicates + expect(Event.exists?(dup_id)).to be false + expect(Event.where(title: dup_title).count).to eq 3 # original + 2 remaining duplicates end it 'can individually edit duplicates' do @@ -215,15 +220,16 @@ click_button('Create Copies') # Wait for duplicates to be queryable (database transaction visibility) - new_events = nil + current_count = Event.where(title: original_event.title).count Timeout.timeout(5) do loop do - new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) - break if new_events&.all?(&:present?) + new_count = Event.where(title: original_event.title).count + break if new_count >= current_count + 2 sleep 0.1 end end + new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) new_events.each do |event| event.update(max_attendees: 100 + i) end @@ -239,19 +245,20 @@ click_button('Duplicate') fill_in('count', with: 2) click_button('Create Copies') - + # Wait for duplicates to be queryable (database transaction visibility) # This ensures the POST request completes before visiting the page again - expected_count = 1 + 2 * (iteration + 1) + expected_count = 1 + (2 * (iteration + 1)) Timeout.timeout(5) do loop do actual_count = Event.where(title: original_event.title).count break if actual_count >= expected_count + sleep 0.1 end end end - + all_events = Event.where(title: original_event.title) all_events.each do |event| expect(event.speakers).to include(speaker) diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 524bcf0cc..4929d449c 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true -require 'database_cleaner/active_record' rescue require 'database_cleaner' +begin + require 'database_cleaner/active_record' +rescue LoadError + require 'database_cleaner' +end + RSpec.configure do |config| config.before(:suite) do DatabaseCleaner.clean_with(:truncation) From d01c55fc2aeaba9da2b4b6fc840e37a7dcbd1a30 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 01:12:21 -0800 Subject: [PATCH 13/26] More test fixes --- spec/controllers/admin/events_controller_spec.rb | 14 ++++++-------- spec/features/event_duplication_spec.rb | 10 +++++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/spec/controllers/admin/events_controller_spec.rb b/spec/controllers/admin/events_controller_spec.rb index fa328d500..308f54d92 100644 --- a/spec/controllers/admin/events_controller_spec.rb +++ b/spec/controllers/admin/events_controller_spec.rb @@ -9,9 +9,9 @@ # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 # (the numbers could be different as long as there is this matching of IDs). # We implemented or own where method to solve this and those ids are for testing this case. - let!(:event_without_commercial) { create(:event, program: conference.program) } - let!(:event_with_commercial) { create(:event, program: conference.program) } - let!(:event_commercial) { create(:event_commercial, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } + let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } + let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } + let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } with_versioning do describe 'GET #show' do @@ -70,13 +70,13 @@ end.to change(Event, :count).by(1) end - it 'redirects to the new event page' do + it 'redirects to the events page' do post :duplicate, params: { conference_id: dup_conference.short_title, id: original_event.id, count: 1 } - expect(response).to redirect_to(admin_conference_program_event_path(dup_conference.short_title, Event.last)) + expect(response).to redirect_to(admin_conference_program_event_path(dup_conference.short_title)) end it 'sets a success flash message' do @@ -110,7 +110,7 @@ end.to change(Event, :count).by(3) end - it 'redirects to the events index when creating multiple' do + it 'redirects to the events page' do post :duplicate, params: { conference_id: dup_conference.short_title, id: original_event.id, @@ -299,7 +299,6 @@ end it 'can duplicate a duplicate' do - # Evaluate first_duplicate before the expect block to avoid counting its creation dup_id = first_duplicate.id expect do post :duplicate, params: { @@ -378,7 +377,6 @@ end it 'updating original does not affect duplicates' do - # Create duplicates first to capture original title dups = duplicates original_title = original_event.title new_title = 'Updated Original Title' diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 9e0bd8bb4..385b687de 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -176,12 +176,12 @@ sleep 0.1 end end + @duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) end it 'deleting a duplicate does not affect others' do - duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) - dup_id = duplicates.first.id - dup_title = duplicates.first.title + dup_id = @duplicates.first.id + dup_title = @duplicates.first.title visit admin_conference_program_event_path(conference.short_title, Event.find(dup_id)) accept_confirm do @@ -215,12 +215,12 @@ expect do 3.times do |i| visit admin_conference_program_event_path(conference.short_title, original_event) + current_count = Event.where(title: original_event.title).count click_button('Duplicate') fill_in('count', with: 2) click_button('Create Copies') - # Wait for duplicates to be queryable (database transaction visibility) - current_count = Event.where(title: original_event.title).count + # Wait for duplicates to be queryable (database transaction visibility) Timeout.timeout(5) do loop do new_count = Event.where(title: original_event.title).count From aee09c54212978c8597e57dac833c6b7419c57b8 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 01:20:46 -0800 Subject: [PATCH 14/26] Implement Event Duplication --- .../admin/events_controller_spec.rb | 22 +++++++++---------- spec/features/event_duplication_spec.rb | 3 +++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/spec/controllers/admin/events_controller_spec.rb b/spec/controllers/admin/events_controller_spec.rb index 308f54d92..bc035eb41 100644 --- a/spec/controllers/admin/events_controller_spec.rb +++ b/spec/controllers/admin/events_controller_spec.rb @@ -3,18 +3,18 @@ require 'spec_helper' describe Admin::EventsController do - let(:conference) { create(:conference) } - let!(:organizer) { create(:organizer, resource: conference) } - # The where_object() and where_object_changes() methods of paper_trail gem are broken when having: - # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 - # (the numbers could be different as long as there is this matching of IDs). - # We implemented or own where method to solve this and those ids are for testing this case. - let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } - let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } - let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } - with_versioning do describe 'GET #show' do + let(:conference) { create(:conference) } + let!(:organizer) { create(:organizer, resource: conference) } + # The where_object() and where_object_changes() methods of paper_trail gem are broken when having: + # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 + # (the numbers could be different as long as there is this matching of IDs). + # We implemented or own where method to solve this and those ids are for testing this case. + let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } + let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } + let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } + before do sign_in(organizer) get :show, params: { id: event_without_commercial.id, conference_id: conference.short_title } @@ -76,7 +76,7 @@ id: original_event.id, count: 1 } - expect(response).to redirect_to(admin_conference_program_event_path(dup_conference.short_title)) + expect(response).to redirect_to(admin_conference_program_events_path(dup_conference.short_title)) end it 'sets a success flash message' do diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index 385b687de..eeced285b 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -201,6 +201,9 @@ fill_in('event_subtitle', with: new_subtitle) click_button('Update Proposal') + # Wait for the update to complete and page to redirect + expect(page).to have_content(original_event.title) + duplicate.reload expect(duplicate.subtitle).to eq new_subtitle From 901cfdc7dfac36f765dbb771e89c36a7172054bb Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 01:23:20 -0800 Subject: [PATCH 15/26] Implement Event Duplication --- app/controllers/admin/events_controller.rb | 10 ++++---- spec/features/event_duplication_spec.rb | 30 ++++++++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 3ed146121..3b8c234d7 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -195,11 +195,11 @@ def duplicate duplicator = EventDuplicator.new(@event, current_user) duplicated_events = duplicator.duplicate(count) - if duplicated_events.length == 1 - flash[:notice] = "Event '#{duplicated_events.first.title}' duplicated successfully." - else - flash[:notice] = "#{duplicated_events.length} copies of '#{@event.title}' created successfully." - end + flash[:notice] = if duplicated_events.length == 1 + "Event '#{duplicated_events.first.title}' duplicated successfully." + else + "#{duplicated_events.length} copies of '#{@event.title}' created successfully." + end redirect_to admin_conference_program_events_path(@conference.short_title) rescue StandardError => e flash[:alert] = "Could not duplicate event: #{e.message}" diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb index eeced285b..aca1e93a0 100644 --- a/spec/features/event_duplication_spec.rb +++ b/spec/features/event_duplication_spec.rb @@ -9,7 +9,7 @@ let(:event_type) { create(:event_type, program: program) } let(:track) { create(:track, state: 'confirmed', program: program) } let(:difficulty_level) { create(:difficulty_level, program: program) } - + let(:speaker) { create(:user) } let(:venue) { conference.venue || create(:venue, conference: conference) } let(:room) { create(:room, venue: venue) } @@ -160,6 +160,8 @@ end describe 'deleting and updating duplicates' do + let(:duplicates) { Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) } + before do visit admin_conference_program_event_path(conference.short_title, original_event) click_button('Duplicate') @@ -176,12 +178,11 @@ sleep 0.1 end end - @duplicates = Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) end it 'deleting a duplicate does not affect others' do - dup_id = @duplicates.first.id - dup_title = @duplicates.first.title + dup_id = duplicates.first.id + dup_title = duplicates.first.title visit admin_conference_program_event_path(conference.short_title, Event.find(dup_id)) accept_confirm do @@ -194,19 +195,19 @@ end it 'can individually edit duplicates' do - duplicate = @duplicates.first + duplicate = duplicates.first new_subtitle = 'Updated Subtitle' - + visit edit_admin_conference_program_event_path(conference.short_title, duplicate) fill_in('event_subtitle', with: new_subtitle) click_button('Update Proposal') - + # Wait for the update to complete and page to redirect expect(page).to have_content(original_event.title) - + duplicate.reload expect(duplicate.subtitle).to eq new_subtitle - + # Original should not be affected original_event.reload expect(original_event.subtitle).not_to eq new_subtitle @@ -218,25 +219,26 @@ expect do 3.times do |i| visit admin_conference_program_event_path(conference.short_title, original_event) - current_count = Event.where(title: original_event.title).count + current_count = Event.where(title: original_event.title).count click_button('Duplicate') fill_in('count', with: 2) click_button('Create Copies') - - # Wait for duplicates to be queryable (database transaction visibility) + + # Wait for duplicates to be queryable (database transaction visibility) Timeout.timeout(5) do loop do new_count = Event.where(title: original_event.title).count break if new_count >= current_count + 2 + sleep 0.1 end end - + new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) new_events.each do |event| event.update(max_attendees: 100 + i) end - + new_events.first&.destroy end end.not_to raise_error From 8e53c08f9371c710645d27867d3b4167191941dd Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 01:48:21 -0800 Subject: [PATCH 16/26] update ruby version --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8e00fba4..4659fb522 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -832,7 +832,7 @@ DEPENDENCIES whenever RUBY VERSION - ruby 3.3.8p144 + ruby 3.3.10p183 BUNDLED WITH 2.5.6 From 36ccd156f7d10b263b75387eba6ec907d9b5545e Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 02:03:51 -0800 Subject: [PATCH 17/26] Bump ruby to 3.3.10 --- .ruby-version | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.ruby-version b/.ruby-version index 37d02a6e3..5f6fc5edc 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.8 +3.3.10 diff --git a/.tool-versions b/.tool-versions index c47fe8b0d..c0c3bc749 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -ruby 3.3.8 +ruby 3.3.10 nodejs 16.20.2 From 4e0f88a2f70d24258ab11f953443d9fcdd0a2d66 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 6 Mar 2026 10:44:44 -0800 Subject: [PATCH 18/26] Darwin in the gemfile --- Gemfile.lock | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 834cb45fa..8f14c5426 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,6 +254,7 @@ GEM faraday (~> 2.0) fastimage (2.3.0) feature (1.4.0) + ffi (1.17.0-arm64-darwin) ffi (1.17.0-x64-mingw-ucrt) ffi (1.17.0-x86_64-linux-gnu) font-awesome-sass (6.5.1) @@ -383,6 +384,8 @@ GEM next_rails (1.3.0) colorize (>= 0.8.1) nio4r (2.7.0) + nokogiri (1.16.6-arm64-darwin) + racc (~> 1.4) nokogiri (1.16.6-x64-mingw-ucrt) racc (~> 1.4) nokogiri (1.16.6-x86_64-linux) @@ -641,6 +644,7 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sqlite3 (1.7.2-arm64-darwin) sqlite3 (1.7.2-x64-mingw-ucrt) sqlite3 (1.7.2-x86_64-linux) ssrf_filter (1.1.2) @@ -668,8 +672,6 @@ GEM turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.3) - tzinfo (>= 1.0.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unicode-display_width (2.5.0) @@ -705,6 +707,7 @@ GEM zeitwerk (2.6.13) PLATFORMS + arm64-darwin-24 x64-mingw-ucrt x86_64-linux From 60d3f72ed6b93e4cfec0a9d56bbd3b42ec4ca6ca Mon Sep 17 00:00:00 2001 From: li-xinwei Date: Tue, 10 Mar 2026 19:35:32 -0700 Subject: [PATCH 19/26] Fix registration form not displaying validation errors The sign-up form silently fails when validation errors occur (e.g., duplicate username, password too short, password mismatch) because the Devise registration views never render resource.errors. Add error message rendering to the _form_fields partial so users see clear feedback when registration fails. Fixes #61 --- app/views/devise/registrations/_form_fields.html.haml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/views/devise/registrations/_form_fields.html.haml b/app/views/devise/registrations/_form_fields.html.haml index 5425d7474..3e070dbc7 100644 --- a/app/views/devise/registrations/_form_fields.html.haml +++ b/app/views/devise/registrations/_form_fields.html.haml @@ -1,3 +1,11 @@ +- if resource.errors.any? + .alert.alert-danger + %h4 + = pluralize(resource.errors.count, 'error') + prohibited this account from being saved: + %ul + - resource.errors.full_messages.each do |message| + %li= message - unless @user.persisted? .form-group = f.label :username From 1b9e7a46f10fae9f1f102d57e1d01c6805e2b808 Mon Sep 17 00:00:00 2001 From: Ethan Stone Date: Fri, 13 Mar 2026 09:56:56 -0700 Subject: [PATCH 20/26] Make Duplication one transaction --- app/controllers/admin/events_controller.rb | 2 +- app/services/event_duplicator.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 3b8c234d7..32583c358 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -202,7 +202,7 @@ def duplicate end redirect_to admin_conference_program_events_path(@conference.short_title) rescue StandardError => e - flash[:alert] = "Could not duplicate event: #{e.message}" + flash[:alert] = "Could not duplicate event" redirect_to admin_conference_program_event_path(@conference.short_title, @event) end diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb index 2a80da7ee..9d4a68df1 100644 --- a/app/services/event_duplicator.rb +++ b/app/services/event_duplicator.rb @@ -8,8 +8,10 @@ def initialize(event, submitter = nil) def duplicate(count = 1) duplicated_events = [] - count.times do - duplicated_events << create_duplicate + @event.class.transaction do + count.times do + duplicated_events << create_duplicate + end end duplicated_events end From d8e50e66e0747c7113fe16cbc8418c4b0d883f0a Mon Sep 17 00:00:00 2001 From: li-xinwei <159217752+li-xinwei@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:23:02 -0700 Subject: [PATCH 21/26] Update info.yml --- info.yml | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/info.yml b/info.yml index 494e6be19..a3da935a7 100644 --- a/info.yml +++ b/info.yml @@ -1,27 +1,24 @@ project: - name: 'snapcon' # Your project name, e.g. Cue-to-cue - owner: 'CS169L-26' # Do not change - teamId: '04' # Your team number, e.g. 02 - identities: - heroku: 'https://sp26-04-snapcon.herokuapp.com' # Your Heroku app URL + name: 'Snapcon' + owner: 'CS169L-Sp26' + teamId: 'CS169L-Sp26' + identities: {} + notifications: + email: 'ethanstone@berkeley.edu,bcsikes@berkeley.edu,zhouyijun@berkeley.edu,xinweili@berkeley.edu' members: - member1: # Add all project members - name: 'Ethan' # Member 1 name - surname: 'Stone' # Member 1 last name - githubUsername: 'Ethan-Stone1' # Member 1 GitHub username - herokuEmail: 'ethanstone@berkeley.edu' # Member 1 Heroku username + member1: + name: 'Xinwei' + surname: 'Li' + githubUsername: 'li-xinwei' member2: - name: 'Benjamin' - surname: 'Sikes' - githubUsername: 'sikesbc' - herokuEmail: 'bcsikes@berkeley.edu' - member3: name: 'Yijun' surname: 'Zhou' githubUsername: 'zhouyijun111' - herokuEmail: 'zhouyijun@berkeley.edu' + member3: + name: 'Ethan' + surname: 'Stone' + githubUsername: 'Ethan-Stone1' member4: - name: 'Xinwei' - surname: 'Li' - githubUsername: 'li-xinwei' - herokuEmail: 'xinweili@berkeley.edu' \ No newline at end of file + name: 'Benjamin' + surname: 'Sikes' + githubUsername: 'sikesbc' From 69bea7a47346cdb553289199cfedc00fad569575 Mon Sep 17 00:00:00 2001 From: Yijun Zhou Date: Fri, 20 Mar 2026 01:55:37 -0700 Subject: [PATCH 22/26] Respect organizer email_notifications when sending proposal comment emails --- app/assets/javascripts/osem-switch.js | 62 ++++++++++++++++--- app/controllers/admin/roles_controller.rb | 32 +++++++++- app/models/admin_ability.rb | 2 +- app/models/user.rb | 15 ++++- app/models/users_role.rb | 7 ++- app/views/admin/roles/_users.html.haml | 17 +++-- app/views/admin/roles/show.html.haml | 2 +- .../roles/toggle_comment_notifications.js.erb | 1 + config/routes.rb | 1 + ..._add_email_notifications_to_users_roles.rb | 5 ++ db/schema.rb | 3 +- 11 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 app/views/admin/roles/toggle_comment_notifications.js.erb create mode 100644 db/migrate/20260320100000_add_email_notifications_to_users_roles.rb diff --git a/app/assets/javascripts/osem-switch.js b/app/assets/javascripts/osem-switch.js index b4517c1cb..dd60d576d 100644 --- a/app/assets/javascripts/osem-switch.js +++ b/app/assets/javascripts/osem-switch.js @@ -1,15 +1,61 @@ function checkboxSwitch(selector){ $(selector).bootstrapSwitch(); - $(selector).on('switchChange.bootstrapSwitch', function(event, state) { - var url = $(this).attr('url') + state; - var method = $(this).attr('method') || 'patch'; + // Prevent duplicated event handlers when the page is re-rendered. + $(selector).off('switchChange.bootstrapSwitch'); + $(selector).off('.osemSwitchGuard'); - $.ajax({ - url: url, - type: method, - dataType: 'script' - }); + // Mark as user-initiated before bootstrapSwitch triggers switchChange. + // Important: bootstrapSwitch often binds clicks on its generated wrapper/label, + // so we must listen on those too (not only on the hidden checkbox input). + $(selector).each(function() { + var $input = $(this); + $input.data('osem-user-toggle', false); + + var $wrapper = $input.closest('.bootstrap-switch'); + if ($wrapper.length === 0) { + $wrapper = $input.parent(); + } + + $wrapper.off('click.osemSwitchGuard mouseup.osemSwitchGuard touchend.osemSwitchGuard pointerup.osemSwitchGuard'); + $wrapper.on( + 'click.osemSwitchGuard mouseup.osemSwitchGuard touchend.osemSwitchGuard pointerup.osemSwitchGuard', + function() { + $input.data('osem-user-toggle', true); + } + ); + }); + + $(selector).on('switchChange.bootstrapSwitch', function(_event, state) { + var $el = $(this); + if (!$el.data('osem-user-toggle')) { + return; + } + + // bootstrapSwitch can emit multiple switchChange events per user click. + // Delay the request slightly, then read the final checkbox state to send once. + var existingTimer = $el.data('osem-user-toggle-timer'); + if (existingTimer) { + clearTimeout(existingTimer); + } + + var method = $el.attr('method') || 'patch'; + var urlBase = $el.attr('url'); + + var timer = setTimeout(function() { + $el.data('osem-user-toggle', false); + + var checked = $el.is(':checked'); + var url = urlBase + (checked ? 'true' : 'false'); + + $.ajax({ + url: url, + type: method, + dataType: 'script' + }); + }, 180); + + $el.data('osem-user-toggle-timer', timer); }); } diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index ca455dce8..aff52e410 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -6,7 +6,7 @@ class RolesController < Admin::BaseController before_action :set_selection authorize_resource :role, except: :index # Show flash message with ajax calls - after_action :prepare_unobtrusive_flash, only: :toggle_user + after_action :prepare_unobtrusive_flash, only: %i[toggle_user toggle_comment_notifications] def index @roles = Role.where(resource: @conference) @@ -21,7 +21,11 @@ def show else toggle_user_admin_conference_role_path(@conference.short_title, @role.name) end - @users = @role.users + @users_roles = UsersRole.where(role: @role).includes(:user) + @comment_notifications_url = + if @track.nil? + toggle_comment_notifications_admin_conference_role_path(@conference.short_title, @role.name) + end end def edit @@ -103,6 +107,30 @@ def toggle_user end end + def toggle_comment_notifications + user = User.find_by(email: user_params[:email]) + state = user_params[:state] + + redirect_url = admin_conference_role_path(@conference.short_title, @role.name) + unless user + redirect_to redirect_url, error: 'Could not find user. Please provide a valid email!' and return + end + + users_role = UsersRole.find_by(user: user, role: @role) + unless users_role + redirect_to redirect_url, error: 'Could not find organizer setting for this user.' and return + end + + # Be tolerant to different representations coming from the client (e.g. "true", "1", true). + email_notifications = ActiveModel::Type::Boolean.new.cast(state) + users_role.update!(email_notifications: email_notifications) + + respond_to do |format| + format.js + format.html { redirect_to redirect_url, notice: 'Successfully updated notification setting.' } + end + end + protected def set_selection diff --git a/app/models/admin_ability.rb b/app/models/admin_ability.rb index 07e72ec20..bc48dcc33 100644 --- a/app/models/admin_ability.rb +++ b/app/models/admin_ability.rb @@ -163,7 +163,7 @@ def signed_in_with_organizer_role(user, conf_ids_for_organization_admin = []) role.resource_type == 'Conference' || role.resource_type == 'Track' end - can [:edit, :update, :toggle_user], Role do |role| + can [:edit, :update, :toggle_user, :toggle_comment_notifications], Role do |role| (role.resource_type == 'Conference' && (conf_ids.include? role.resource_id)) || (role.resource_type == 'Track' && (track_ids.include? role.resource_id)) end diff --git a/app/models/user.rb b/app/models/user.rb index bfa75bb97..6d5d67974 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -102,7 +102,20 @@ def for_registration(conference) # add scope scope :comment_notifiable, lambda { |conference| - joins(:roles).where('roles.name IN (?)', %i[organizer cfp]).where('roles.resource_type = ? AND roles.resource_id = ?', 'Conference', conference.id) + joins(users_roles: :role) + .where(roles: { resource_type: 'Conference', + resource_id: conference.id, + name: %i[organizer cfp] }) + .where( + Role.arel_table[:name] + .eq('cfp') + .or( + Role.arel_table[:name] + .eq('organizer') + .and(UsersRole.arel_table[:email_notifications].eq(true)) + ) + ) + .distinct } # scopes for user distributions diff --git a/app/models/users_role.rb b/app/models/users_role.rb index 621d42d37..53c755daa 100644 --- a/app/models/users_role.rb +++ b/app/models/users_role.rb @@ -4,9 +4,10 @@ # # Table name: users_roles # -# id :bigint not null, primary key -# role_id :integer -# user_id :integer +# id :bigint not null, primary key +# email_notifications :boolean default(TRUE), not null +# role_id :integer +# user_id :integer # # Indexes # diff --git a/app/views/admin/roles/_users.html.haml b/app/views/admin/roles/_users.html.haml index bde7856bc..2c2b04a22 100644 --- a/app/views/admin/roles/_users.html.haml +++ b/app/views/admin/roles/_users.html.haml @@ -1,6 +1,6 @@ .page-header - %h3 Users (#{users.length}) -- if users.present? + %h3 Users (#{users_roles.length}) +- if users_roles.present? %table.datatable#users %thead - if ( can? :toggle_user, @role ) @@ -8,15 +8,24 @@ %th ID %th Name %th Email + - if @role.name == 'organizer' && can?(:toggle_user, @role) + %th Email notifications %tbody - - users.each do |user| + - users_roles.each do |users_role| + - user = users_role.user %tr - if ( can? :toggle_user, @role ) %td.text-right = hidden_field_tag "role[user_ids][]", nil - = check_box_tag @conference.short_title, @role.id, (@role.user_ids.include? user.id), url: "#{@url}?user[email]=#{user.email}&user[state]=", method: :post, class: 'switch-checkbox' + = check_box_tag @conference.short_title, @role.id, true, url: "#{@url}?user[email]=#{user.email}&user[state]=", method: :post, class: 'switch-checkbox' %td= user.id %td= user.name %td= user.email + - if @role.name == 'organizer' && can?(:toggle_user, @role) + %td + = check_box_tag 'email_notifications', users_role.id, users_role.email_notifications, + url: "#{@comment_notifications_url}?user[email]=#{user.email}&user[state]=", + method: :post, + class: 'switch-checkbox' - else %h5 No users found! diff --git a/app/views/admin/roles/show.html.haml b/app/views/admin/roles/show.html.haml index 629427fb1..18761ecc2 100644 --- a/app/views/admin/roles/show.html.haml +++ b/app/views/admin/roles/show.html.haml @@ -30,4 +30,4 @@ .row .col-md-12 #users_area - = render partial: 'users', locals: { users: @users } + = render partial: 'users', locals: { users_roles: @users_roles } diff --git a/app/views/admin/roles/toggle_comment_notifications.js.erb b/app/views/admin/roles/toggle_comment_notifications.js.erb new file mode 100644 index 000000000..6c67f5c33 --- /dev/null +++ b/app/views/admin/roles/toggle_comment_notifications.js.erb @@ -0,0 +1 @@ +$(".alert").remove(); diff --git a/config/routes.rb b/config/routes.rb index 62ac3dfdd..48be7ce48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -139,6 +139,7 @@ resources :roles, except: %i[new create] do member do post :toggle_user + post :toggle_comment_notifications end end diff --git a/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb b/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb new file mode 100644 index 000000000..b910699d3 --- /dev/null +++ b/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb @@ -0,0 +1,5 @@ +class AddEmailNotificationsToUsersRoles < ActiveRecord::Migration[7.0] + def change + add_column :users_roles, :email_notifications, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 18d82ff98..e5e7ae807 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_01_042356) do +ActiveRecord::Schema[7.0].define(version: 2026_03_20_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -638,6 +638,7 @@ create_table "users_roles", force: :cascade do |t| t.integer "role_id" t.integer "user_id" + t.boolean "email_notifications", default: true, null: false t.index ["user_id", "role_id"], name: "index_users_roles_on_user_id_and_role_id" end From 8f5adc5528112cba7e2a964423cd01109d09d633 Mon Sep 17 00:00:00 2001 From: li-xinwei <159217752+li-xinwei@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:51:37 -0700 Subject: [PATCH 23/26] Migrate from Stripe Charges API to Stripe Checkout Sessions API Replace legacy Stripe Charges API with modern Checkout Sessions API. Users are redirected to Stripe-hosted payment page with itemized ticket breakdown, multi-currency support, and session-based payment confirmation. Also fixes registration form error display, payment button layout, and various linter offenses. --- .rubocop_todo.yml | 2 + Gemfile | 2 +- Gemfile.lock | 6 +- app/assets/stylesheets/osem-payments.scss | 7 +- app/controllers/admin/events_controller.rb | 4 +- app/controllers/payments_controller.rb | 72 ++++-- app/models/payment.rb | 92 ++++++- .../registrations/_form_fields.html.haml | 2 +- app/views/payments/_payment.html.haml | 47 ++-- app/views/payments/new.html.haml | 3 +- config/puma.rb | 4 +- config/routes.rb | 7 +- ...00000_add_stripe_session_id_to_payments.rb | 6 + db/schema.rb | 4 +- spec/factories/payments.rb | 5 + spec/features/ticket_purchases_spec.rb | 85 +----- spec/models/organization_spec.rb | 6 +- spec/models/payment_spec.rb | 241 +++++++++++++----- 18 files changed, 366 insertions(+), 229 deletions(-) create mode 100644 db/migrate/20260305000000_add_stripe_session_id_to_payments.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6f94f9d78..02779506f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -495,6 +495,8 @@ RSpec/SubjectStub: RSpec/VerifiedDoubles: Exclude: - 'spec/datatables/user_datatable_spec.rb' + - 'spec/features/ticket_purchases_spec.rb' + - 'spec/models/payment_spec.rb' - 'spec/pdfs/ticket_pdf_spec.rb' # Offense count: 1 diff --git a/Gemfile b/Gemfile index 9f888c822..346785483 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,7 @@ gem 'cloudinary' # for internationalizing gem 'rails-i18n' # Windows: timezone data (required on Windows for tzinfo) -gem 'tzinfo-data', platforms: %i[ windows jruby ] +gem 'tzinfo-data', platforms: %i[windows jruby] # as authentification framework gem 'devise' diff --git a/Gemfile.lock b/Gemfile.lock index 8f14c5426..fe1021eb6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -672,6 +672,8 @@ GEM turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + tzinfo-data (1.2025.3) + tzinfo (>= 1.0.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unicode-display_width (2.5.0) @@ -707,7 +709,7 @@ GEM zeitwerk (2.6.13) PLATFORMS - arm64-darwin-24 + arm64-darwin-25 x64-mingw-ucrt x86_64-linux @@ -830,7 +832,7 @@ DEPENDENCIES whenever RUBY VERSION - ruby 3.3.10p183 + ruby 3.3.8p144 BUNDLED WITH 2.5.6 diff --git a/app/assets/stylesheets/osem-payments.scss b/app/assets/stylesheets/osem-payments.scss index cfa451b40..b4a34130e 100644 --- a/app/assets/stylesheets/osem-payments.scss +++ b/app/assets/stylesheets/osem-payments.scss @@ -1,3 +1,6 @@ -.stripe-button-el { - float: right; +.payment-actions { + margin-top: 15px; + display: flex; + justify-content: space-between; + align-items: center; } diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 32583c358..6881ab38e 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -201,8 +201,8 @@ def duplicate "#{duplicated_events.length} copies of '#{@event.title}' created successfully." end redirect_to admin_conference_program_events_path(@conference.short_title) - rescue StandardError => e - flash[:alert] = "Could not duplicate event" + rescue StandardError + flash[:alert] = 'Could not duplicate event' redirect_to admin_conference_program_event_path(@conference.short_title, @event) end diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 17a4b94b2..1d28b81a4 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -2,7 +2,7 @@ class PaymentsController < ApplicationController before_action :authenticate_user! - load_and_authorize_resource + load_and_authorize_resource only: %i[index new] load_resource :conference, find_by: :short_title authorize_resource :conference_registrations, class: Registration @@ -11,7 +11,6 @@ def index end def new - # TODO: use "base currency" session[:selected_currency] = params[:currency] if params[:currency].present? selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency from_currency = @conference.tickets.first.price_currency @@ -27,15 +26,51 @@ def new end def create - @payment = Payment.new payment_params session[:selected_currency] = params[:currency] if params[:currency].present? selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency - from_currency = @conference.tickets.first.price_currency - if @payment.purchase && @payment.save - update_purchased_ticket_purchases + @payment = Payment.new( + user: current_user, + conference: @conference, + currency: selected_currency + ) + authorize! :create, @payment + + unless @payment.save + redirect_to new_conference_payment_path(@conference.short_title), + error: @payment.errors.full_messages.to_sentence + return + end + + session[:has_registration_ticket] = params[:has_registration_ticket] + + checkout_session = @payment.create_checkout_session( + success_url: success_conference_payments_url(@conference.short_title) + '?session_id={CHECKOUT_SESSION_ID}', + cancel_url: cancel_conference_payments_url(@conference.short_title) + ) + + if checkout_session + redirect_to checkout_session.url, allow_other_host: true + else + @payment.destroy + redirect_to new_conference_payment_path(@conference.short_title), + error: @payment.errors.full_messages.to_sentence.presence || 'Could not create checkout session. Please try again.' + end + end - has_registration_ticket = params[:has_registration_ticket] + def success + @payment = Payment.find_by(stripe_session_id: params[:session_id]) + + if @payment.nil? + redirect_to new_conference_payment_path(@conference.short_title), + error: 'Payment not found. Please try again.' + return + end + + if @payment.complete_checkout + update_purchased_ticket_purchases(@payment) + + has_registration_ticket = session.delete(:has_registration_ticket) if has_registration_ticket == 'true' registration = @conference.register_user(current_user) if registration @@ -50,26 +85,21 @@ def create notice: 'Thanks! Your ticket is booked successfully.' end else - # TODO-SNAPCON: This case is not tested at all - @total_amount_to_pay = CurrencyConversion.convert_currency(@conference, Ticket.total_price(@conference, current_user, paid: false), from_currency, selected_currency) - @unpaid_ticket_purchases = current_user.ticket_purchases.unpaid.by_conference(@conference) - flash.now[:error] = @payment.errors.full_messages.to_sentence + ' Please try again with correct credentials.' - render :new + redirect_to new_conference_payment_path(@conference.short_title), + error: 'Payment could not be completed. Please try again.' end end - private - - def payment_params - params.permit(:stripe_customer_email, :stripe_customer_token) - .merge(stripe_customer_email: params[:stripeEmail], - stripe_customer_token: params[:stripeToken], - user: current_user, conference: @conference, currency: session[:selected_currency]) + def cancel + redirect_to new_conference_payment_path(@conference.short_title), + notice: 'Payment was cancelled. You can try again when ready.' end - def update_purchased_ticket_purchases + private + + def update_purchased_ticket_purchases(payment) current_user.ticket_purchases.by_conference(@conference).unpaid.each do |ticket_purchase| - ticket_purchase.pay(@payment) + ticket_purchase.pay(payment) end end end diff --git a/app/models/payment.rb b/app/models/payment.rb index 5fa61c2f9..74927bcb3 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -13,15 +13,18 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# class Payment < ApplicationRecord has_many :ticket_purchases belongs_to :user belongs_to :conference - attr_accessor :stripe_customer_email, :stripe_customer_token - validates :status, presence: true validates :user_id, presence: true validates :conference_id, presence: true @@ -41,21 +44,82 @@ def stripe_description "Tickets for #{conference.title} #{user.name} #{user.email}" end - def purchase - gateway_response = Stripe::Charge.create source: stripe_customer_token, - receipt_email: stripe_customer_email, - description: stripe_description, - amount: amount_to_pay, - currency: currency - - self.amount = gateway_response[:amount] - self.last4 = gateway_response[:source][:last4] - self.authorization_code = gateway_response[:id] - self.status = 'success' - true + def unpaid_ticket_purchases + user.ticket_purchases.unpaid.by_conference(conference) + end + + def create_checkout_session(success_url:, cancel_url:) + line_items = build_line_items + return nil if line_items.empty? + + session = Stripe::Checkout::Session.create( + payment_method_types: ['card'], + mode: 'payment', + customer_email: user.email, + line_items: line_items, + success_url: success_url, + cancel_url: cancel_url, + metadata: { + payment_id: id, + conference_id: conference_id, + user_id: user_id + } + ) + + update(stripe_session_id: session.id) + session + rescue Stripe::StripeError => e + self.status = 'failure' + save + errors.add(:base, e.message) + nil + end + + def complete_checkout + session = Stripe::Checkout::Session.retrieve( + id: stripe_session_id, + expand: ['payment_intent.latest_charge'] + ) + + if session.payment_status == 'paid' + charge = session.payment_intent&.latest_charge + + self.amount = session.amount_total + self.last4 = charge&.payment_method_details&.card&.last4 + self.authorization_code = session.payment_intent&.id + self.status = 'success' + save + else + self.status = 'failure' + save + false + end rescue Stripe::StripeError => e errors.add(:base, e.message) self.status = 'failure' + save false end + + private + + def build_line_items + unpaid_ticket_purchases.includes(:ticket).map do |tp| + unit_amount = CurrencyConversion.convert_currency( + conference, tp.ticket.price, tp.ticket.price_currency, currency + ).fractional + + { + price_data: { + currency: currency.downcase, + product_data: { + name: tp.title, + description: tp.description.presence || "#{conference.title} - #{tp.title}" + }, + unit_amount: unit_amount + }, + quantity: tp.quantity + } + end + end end diff --git a/app/views/devise/registrations/_form_fields.html.haml b/app/views/devise/registrations/_form_fields.html.haml index 3e070dbc7..36be44b2e 100644 --- a/app/views/devise/registrations/_form_fields.html.haml +++ b/app/views/devise/registrations/_form_fields.html.haml @@ -1,4 +1,4 @@ -- if resource.errors.any? +- if defined?(resource) && resource.errors.any? .alert.alert-danger %h4 = pluralize(resource.errors.count, 'error') diff --git a/app/views/payments/_payment.html.haml b/app/views/payments/_payment.html.haml index 76544211e..41c6ba5f8 100644 --- a/app/views/payments/_payment.html.haml +++ b/app/views/payments/_payment.html.haml @@ -1,31 +1,20 @@ -.div - .col-md-12.table-responsive - %table.table.table-hover.table-striped - %thead - %tr - %th Ticket - %th Quantity - %th Price - %th Total - %tbody +.table-responsive + %table.table.table-hover.table-striped + %thead + %tr + %th Ticket + %th Quantity + %th Price + %th Total + %tbody - @unpaid_ticket_purchases.each do |ticket| %tr - %td - = ticket.title - %td - = ticket.quantity - %td - = humanized_money_with_symbol ticket.purchase_price - %td - = humanized_money_with_symbol ticket.purchase_price * ticket.quantity -= form_tag conference_payments_path(@conference.short_title, :has_registration_ticket => @has_registration_ticket) do - %script.stripe-button{ src: "https://checkout.stripe.com/checkout.js", - data: { amount: @total_amount_to_pay.cents, - label: "Pay #{humanized_money_with_symbol @total_amount_to_pay}", - email: current_user.email, - currency: @currency, - name: ENV.fetch('OSEM_NAME', 'OSEM'), - description: "#{@conference.title} tickets", - key: ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key, - locale: "auto"}} - = link_to 'Edit Purchase', conference_tickets_path(@conference.short_title), class: 'btn btn-default' + %td= ticket.title + %td= ticket.quantity + %td= humanized_money_with_symbol ticket.purchase_price + %td= humanized_money_with_symbol ticket.purchase_price * ticket.quantity += form_tag conference_payments_path(@conference.short_title), method: :post do + = hidden_field_tag :has_registration_ticket, @has_registration_ticket + .payment-actions + = link_to 'Edit Purchase', conference_tickets_path(@conference.short_title), class: 'btn btn-default' + = submit_tag "Pay #{humanized_money_with_symbol @total_amount_to_pay}", class: 'btn btn-success' diff --git a/app/views/payments/new.html.haml b/app/views/payments/new.html.haml index 91a779f08..2e9f7b6e2 100644 --- a/app/views/payments/new.html.haml +++ b/app/views/payments/new.html.haml @@ -3,7 +3,7 @@ .col-xs-6.col-xs-offset-3 %h1 Payment Summary : - = humanized_money_with_symbol Money.new(@total_amount_to_pay) + = humanized_money_with_symbol @total_amount_to_pay .col-xs-8.col-xs-offset-2.well = render partial: 'payment' .row @@ -18,3 +18,4 @@ %small All payments are handled securely by our payment processor, = link_to 'Stripe', 'https://stripe.com', target: '_blank' + \. You will be redirected to Stripe's secure checkout page to complete your payment. diff --git a/config/puma.rb b/config/puma.rb index 69424aac8..93ad7c872 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -27,13 +27,13 @@ worker_count = ENV.fetch('WEB_CONCURRENCY') { Gem.win_platform? ? 0 : 2 }.to_i workers worker_count # Set a 10 minute timeout in development for debugging. -worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' && worker_count > 0 +worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' && worker_count.positive? # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write # process behavior so workers use less memory. -preload_app! if worker_count > 0 +preload_app! if worker_count.positive? lowlevel_error_handler do |ex, env| Sentry.capture_exception( diff --git a/config/routes.rb b/config/routes.rb index 4285ff58e..e5f118dfe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -209,7 +209,12 @@ resource :conference_registration, path: 'register' resources :tickets, only: [:index] resources :ticket_purchases, only: %i[create destroy index] - resources :payments, only: %i[index new create] + resources :payments, only: %i[index new create] do + collection do + get :success + get :cancel + end + end resources :physical_tickets, only: %i[index show] resource :subscriptions, only: %i[create destroy] resource :schedule, only: [:show] do diff --git a/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb new file mode 100644 index 000000000..4b63911bf --- /dev/null +++ b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb @@ -0,0 +1,6 @@ +class AddStripeSessionIdToPayments < ActiveRecord::Migration[7.0] + def change + add_column :payments, :stripe_session_id, :string + add_index :payments, :stripe_session_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 18d82ff98..15c0f7e17 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_01_042356) do +ActiveRecord::Schema[7.0].define(version: 2026_03_05_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -332,6 +332,8 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "currency" + t.string "stripe_session_id" + t.index ["stripe_session_id"], name: "index_payments_on_stripe_session_id", unique: true end create_table "physical_tickets", force: :cascade do |t| diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index a3df512e9..686fcf43e 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -13,8 +13,13 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# FactoryBot.define do factory :payment do user diff --git a/spec/features/ticket_purchases_spec.rb b/spec/features/ticket_purchases_spec.rb index 32bb9fd1f..8771fac04 100644 --- a/spec/features/ticket_purchases_spec.rb +++ b/spec/features/ticket_purchases_spec.rb @@ -14,25 +14,6 @@ end let!(:participant) { create(:user) } - def make_stripe_purchase(card_number = '4242424242424242') - find('.stripe-button-el').click - - stripe_iframe = all('iframe[name=stripe_checkout_app]').last - sleep(5) - Capybara.within_frame stripe_iframe do - expect(page).to have_content(:all, "#{ENV.fetch('OSEM_NAME', nil)} tickets") - fill_in 'Card number', with: card_number - fill_in 'Expiry', with: '08/22' - fill_in 'CVC', with: '123' - click_button '$20.00' - sleep(20) - end - end - - def make_failed_stripe_purchase - make_stripe_purchase('4000000000000341') - end - context 'as a participant' do before do sign_in participant @@ -43,34 +24,12 @@ def make_failed_stripe_purchase end context 'who is not registered' do - it 'purchases and pays for a ticket succcessfully' do - visit root_path - click_link 'Register' - - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - click_button 'Register' - - fill_in "tickets__#{ticket.id}", with: '2' - expect(page).to have_current_path(conference_tickets_path(conference.short_title), ignore_query: true) + it 'purchases and is redirected to Stripe Checkout' do + mock_session = double('Stripe::Checkout::Session', + id: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123') + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) - click_button 'Continue' - page.find('#flash') - expect(page).to have_current_path(new_conference_payment_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Please pay here to get tickets.') - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end - end - - it 'purchases ticket but payment fails', feature: true, js: true do visit root_path click_link 'Register' @@ -87,13 +46,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_failed_stripe_purchase - page.find('#flash') - expect(page).to have_current_path(conference_payments_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Your card was declined. Please try again with correct credentials.') - end end it 'purchases free tickets' do @@ -152,13 +104,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: third_registration_ticket.id).first expect(purchase.quantity).to eq(1) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end it 'purchases more than one registration tickets of a single type' do @@ -238,13 +183,6 @@ def make_failed_stripe_purchase expect(purchase.quantity).to eq(1) expect(purchase.currency).to eq('EUR') expect(purchase.amount_paid).to eq(17.80) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end end @@ -267,19 +205,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - - click_button 'Unregister' - end - - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 9006b5546..ceaa4d688 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -16,12 +16,14 @@ let(:organization) { create(:organization) } describe 'validation' do - xit 'is not valid without a name' do + it 'is not valid without a name' do expect(subject).to validate_presence_of(:name) end end describe 'associations' do - xit { is_expected.to have_many(:conferences).dependent(:destroy) } + it 'has many conferences with dependent destroy' do + expect(subject).to have_many(:conferences).dependent(:destroy) + end end end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index 34b0132d2..4313d5a53 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -13,10 +13,14 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# require 'spec_helper' -require 'stripe_mock' describe Payment do context 'new payment' do @@ -46,117 +50,214 @@ end end - describe '#purchase' do + describe '#create_checkout_session' do let!(:user) { create(:user) } - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: stripe_helper.generate_card_token, - stripe_customer_email: user.email) - end let!(:conference) { create(:conference) } let!(:ticket_1) { create(:ticket, price: 10, price_currency: 'USD', conference: conference) } let!(:tickets) { { ticket_1.id.to_s => '2' } } - let(:stripe_helper) { StripeMock.create_test_helper } + let(:payment) { create(:payment, user: user, conference: conference) } - before { StripeMock.start } + before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } - after { StripeMock.stop } + context 'when the session is created successfully' do + let(:mock_session) do + double('Stripe::Checkout::Session', + id: 'cs_test_session_123', + url: 'https://checkout.stripe.com/pay/cs_test_session_123') + end - before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } + it 'creates a Stripe Checkout Session with line items' do + create_args = nil + allow(Stripe::Checkout::Session).to receive(:create) do |**args| + create_args = args + mock_session + end + + result = payment.create_checkout_session( + success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to eq(mock_session) + expect(create_args).to include( + payment_method_types: ['card'], + mode: 'payment', + customer_email: user.email + ) + expect(create_args[:line_items]).to contain_exactly( + hash_including( + price_data: hash_including( + currency: 'usd', + product_data: hash_including(name: ticket_1.title), + unit_amount: 1000 + ), + quantity: 2 + ) + ) + end + + it 'stores the session id on the payment' do + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) + + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + payment.reload + expect(payment.stripe_session_id).to eq('cs_test_session_123') + end + end + + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:create) + .and_raise(Stripe::StripeError.new('Test error')) + end + + it 'returns nil' do + result = payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to be_nil + end + + it 'sets status to failure' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.status).to eq('failure') + end + + it 'adds error message' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.errors[:base]).to include('Test error') + end + end + end + + describe '#complete_checkout' do + let!(:user) { create(:user) } + let!(:conference) { create(:conference) } + let(:payment) { create(:payment, user: user, conference: conference, stripe_session_id: 'cs_test_123') } + + context 'when the payment was successful' do + let(:mock_charge) do + double('Stripe::Charge', + payment_method_details: double(card: double(last4: '4242'))) + end - context 'when the payment is successful' do - before { payment.purchase } + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'paid', + amount_total: 2000, + payment_intent: double(id: 'pi_test_123', latest_charge: mock_charge)) + end + + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) + end + + it 'sets status to success' do + payment.complete_checkout + expect(payment.status).to eq('success') + end it 'assigns amount' do + payment.complete_checkout expect(payment.amount).to eq(2000) end it 'assigns last4' do + payment.complete_checkout expect(payment.last4).to eq('4242') end - it "assigns 'success' to payment.status" do - expect(payment.status).to eq('success') + it 'assigns authorization_code from payment intent' do + payment.complete_checkout + expect(payment.authorization_code).to eq('pi_test_123') end + end - it 'assigns authorization_code' do - expect(payment.authorization_code).to eq('test_ch_3') + context 'when the payment was not successful' do + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'unpaid', + payment_intent: nil) end - it 'assigns currency' do - expect(payment.currency).to eq('USD') + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) end - end - context 'if the payment is not successful' do - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: 'bogus_card_token', - stripe_customer_email: user.email) + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - before { payment.purchase } + it 'returns false' do + expect(payment.complete_checkout).to be false + end + end - context 'when the card is invalid' do - it 'returns false' do - payment_result = payment.purchase - expect(payment_result).to be false - end + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::APIConnectionError.new('Connection failed')) + end - it 'assigns "failure" to payment.status' do - expect(payment.status).to eq('failure') - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error + end - it 'adds errors' do - expect(payment.errors[:base].count).to eq(1) - end + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - context 'when the connection to Stripe drops' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIConnectionError.new) - expect { payment.purchase }.not_to raise_error - end + it 'returns false' do + expect(payment.complete_checkout).to be false end + end - context 'when there is a Stripe API Error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an authentication error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::AuthenticationError.new('Invalid API key')) end - context 'when there is authentication error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::AuthenticationError.new) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when there is a card error' do - it 'raises exception' do - StripeMock.prepare_card_error(:card_declined) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an invalid request' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::InvalidRequestError.new('Invalid session', {})) end - context 'when the request to Stripe is invalid' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::InvalidRequestError.new('Your request is invalid.', {}, code: 402)) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when Stripe rate limit exceeds' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::RateLimitError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when Stripe rate limit exceeds' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::RateLimitError.new('Rate limit exceeded')) end - context 'when the currency is invalid' do - it 'returns false' do - payment.currency = 'ABC' - expect(payment.purchase).to be false - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end end end From 3a8c3f3602535a51b17b301843e7d5c69cc4a2c5 Mon Sep 17 00:00:00 2001 From: Yijun Zhou <94381240+zhouyijun111@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:52:46 -0700 Subject: [PATCH 24/26] Add conference duplication feature (Iteration 2) (#59) --- .../admin/conferences_controller.rb | 45 +++++++++- app/models/conference.rb | 82 +++++++++++++++++++ app/views/admin/conferences/new.html.haml | 6 ++ app/views/admin/conferences/show.html.haml | 1 + 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/conferences_controller.rb b/app/controllers/admin/conferences_controller.rb index 17d2bc131..1a46d3044 100644 --- a/app/controllers/admin/conferences_controller.rb +++ b/app/controllers/admin/conferences_controller.rb @@ -67,19 +67,62 @@ def index end def new - @conference = Conference.new + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference = Conference.new( + description: source.description, + timezone: source.timezone, + start_hour: source.start_hour, + end_hour: source.end_hour, + color: source.color, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + organization_id: source.organization_id + ) + @duplicate_from_source = source.short_title + else + @conference = Conference.new + end + else + @conference = Conference.new + end end def create @conference = Conference.new(conference_params) + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference.assign_attributes( + description: source.description, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + color: source.color, + start_hour: source.start_hour, + end_hour: source.end_hour + ) + end + end + if @conference.save # user that creates the conference becomes organizer of that conference current_user.add_role :organizer, @conference + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + @conference.copy_associations_from(source) if source && can?(:read, source) + end + redirect_to admin_conference_path(id: @conference.short_title), notice: 'Conference was successfully created.' else + @duplicate_from_source = params[:duplicate_from] flash.now[:error] = 'Could not create conference. ' + @conference.errors.full_messages.to_sentence render action: 'new' end diff --git a/app/models/conference.rb b/app/models/conference.rb index 585420049..93ca6e04f 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -841,6 +841,88 @@ def ended? end_date < Time.current end + ## + # Copies associations from another conference (for duplication). + # Includes: registration_period, email_settings, venue+rooms, tickets, + # event_types, tracks, difficulty_levels, sponsorship_levels, sponsors. + # Excludes: events, registrations, and other user/attendee data. + # + def copy_associations_from(source) + return unless source && source != self + + # Registration period (clamp to new conference dates) + if source.registration_period.present? + rp = source.registration_period + start_d = [rp.start_date, start_date].max + end_d = [rp.end_date, end_date].min + start_d = end_d = start_date if start_d > end_d + create_registration_period!(start_date: start_d, end_date: end_d) + end + + # Email settings (conference already has one from create_email_settings) + if source.email_settings.present? && email_settings.present? + attrs = source.email_settings.attributes.except('id', 'conference_id', 'created_at', 'updated_at') + email_settings.update!(attrs) + end + + # Venue and rooms (map old room id -> new room for tracks later) + room_id_map = {} + if source.venue.present? + new_venue = create_venue!( + source.venue.attributes.slice('name', 'street', 'city', 'country', 'description', 'postalcode', 'website', 'latitude', 'longitude') + ) + source.venue.rooms.order(:id).each_with_index do |old_room, _idx| + new_room = new_venue.rooms.create!( + old_room.attributes.slice('name', 'size', 'order').merge(guid: SecureRandom.urlsafe_base64) + ) + room_id_map[old_room.id] = new_room.id + end + end + + # Tickets (conference already has one free ticket from create_free_ticket; skip source's free to avoid duplicate) + source.tickets.each do |t| + next if t.title == 'Free Access' && t.price_cents.zero? + + tickets.create!( + t.attributes.slice('title', 'description', 'price_cents', 'price_currency', 'registration_ticket', 'visible', 'email_subject', 'email_body') + ) + end + + # Event types and difficulty levels (program exists from after_create) + source.program&.event_types&.each do |et| + program.event_types.create!( + et.attributes.slice('title', 'length', 'color', 'description', 'minimum_abstract_length', 'maximum_abstract_length', 'submission_template', 'enable_public_submission') + ) + end + source.program&.difficulty_levels&.each do |dl| + program.difficulty_levels.create!( + dl.attributes.slice('title', 'description', 'color') + ) + end + + # Tracks (assign new room by same index, or nil if no room) + source.program&.tracks&.each do |t| + old_room_id = t.room_id + new_room_id = old_room_id ? room_id_map[old_room_id] : nil + program.tracks.create!( + t.attributes.slice('name', 'short_name', 'description', 'color', 'state', 'relevance', 'start_date', 'end_date', 'cfp_active').merge( + guid: SecureRandom.urlsafe_base64, + room_id: new_room_id + ) + ) + end + + # Sponsorship levels and sponsors + source.sponsorship_levels.each do |sl| + new_sl = sponsorship_levels.create!(sl.attributes.slice('title', 'position')) + sl.sponsors.each do |sp| + new_sl.sponsors.create!( + sp.attributes.slice('name', 'description', 'website_url') + ) + end + end + end + private # Returns a different html colour for every i and consecutive colors are diff --git a/app/views/admin/conferences/new.html.haml b/app/views/admin/conferences/new.html.haml index 2d2ea53e2..c2059562b 100644 --- a/app/views/admin/conferences/new.html.haml +++ b/app/views/admin/conferences/new.html.haml @@ -1,4 +1,10 @@ +- if @duplicate_from_source + .row + .col-md-8 + .alert.alert-info + Duplicating from an existing conference. Please set Title, Short title, and Dates for the new conference. .row .col-md-8 = form_for(@conference, url: admin_conferences_path) do |f| + = hidden_field_tag :duplicate_from, @duplicate_from_source if @duplicate_from_source = render partial: 'form_fields', locals: { f: f } diff --git a/app/views/admin/conferences/show.html.haml b/app/views/admin/conferences/show.html.haml index 8d164643f..11821a2e6 100644 --- a/app/views/admin/conferences/show.html.haml +++ b/app/views/admin/conferences/show.html.haml @@ -1,6 +1,7 @@ %h1 %span.fa-solid.fa-gauge-high Dashboard for #{@conference.title} + = link_to 'Duplicate', new_admin_conference_path(duplicate_from: @conference.short_title), class: 'btn btn-primary btn-sm', style: 'margin-left: 12px;' %hr .row .col-sm-3.col-xs-6 From db28357c98e40896c1f48c71fb2a27cdee034760 Mon Sep 17 00:00:00 2001 From: Yijun Zhou <94381240+zhouyijun111@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:52:52 -0700 Subject: [PATCH 25/26] Add conference duplication feature (Iteration 2) (#59) From 946137e75c7c8b7b2548776c4c4acacd729c290c Mon Sep 17 00:00:00 2001 From: Yijun Zhou <94381240+zhouyijun111@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:53:18 -0700 Subject: [PATCH 26/26] Add conference duplication feature (Iteration 2) (#59)