diff --git a/app/services/cms_content_loaders/conflict_policies/update.rb b/app/services/cms_content_loaders/conflict_policies/update.rb index ba673da6cea..83d99ed3b60 100644 --- a/app/services/cms_content_loaders/conflict_policies/update.rb +++ b/app/services/cms_content_loaders/conflict_policies/update.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class CmsContentLoaders::ConflictPolicies::Update < CmsContentLoaders::ConflictPolicy class SkippedItem attr_reader :existing_item, :new_item, :attrs, :reason @@ -18,12 +19,14 @@ def message attr_reader :previous_content_by_identifier, :skipped_items def initialize(previous_content_by_identifier) + super() @previous_content_by_identifier = previous_content_by_identifier @skipped_items = [] end def all_previous_content_keys - @all_previous_content_keys ||= Set.new(previous_content_by_identifier.flat_map(&:keys)) + @all_previous_content_keys ||= + Set.new(previous_content_by_identifier.values.flat_map { |content| content.keys.map(&:to_s) }) end def previous_content_for(identifier) @@ -35,14 +38,17 @@ def previous_content_for(identifier) end def action_for(existing_item, new_item, attrs) + # nothing in the database to conflict with + return :overwrite unless existing_item.model + previous_content = previous_content_for(existing_item.identifier) modified_keys = [] previous_content.stringify_keys.each do |key, previous_value| - existing_value = existing_item.model&.public_send(key)&.strip - new_value = attrs.stringify_keys[key]&.strip + existing_value = strip_if_string(existing_item.model.public_send(key)) + new_value = strip_if_string(attrs.stringify_keys[key]) - modified_keys << key if existing_value != previous_value&.strip && existing_value != new_value + modified_keys << key if existing_value != strip_if_string(previous_value) && existing_value != new_value end if modified_keys.any? @@ -57,4 +63,11 @@ def action_for(existing_item, new_item, attrs) :overwrite end end + + private + + # (attributes aren't all text; booleans and so on have no whitespace to ignore) + def strip_if_string(value) + value.is_a?(String) ? value.strip : value + end end diff --git a/app/services/export_cms_content_set_service.rb b/app/services/export_cms_content_set_service.rb index aa81eb86881..6b0f25e04ea 100644 --- a/app/services/export_cms_content_set_service.rb +++ b/app/services/export_cms_content_set_service.rb @@ -45,8 +45,8 @@ def export_content_from_adapter(storage_adapter) # rubocop:disable Metrics/Metho inherited_item = inherited_items[item.identifier] if inherited_item - inherited_attrs = storage_adapter.read_item_attrs(inherited_item).stringify_keys.transform_values(&:strip) - own_attrs = item.model.attributes.slice(*inherited_attrs.keys).stringify_keys.transform_values(&:strip) + inherited_attrs = strip_string_values(storage_adapter.read_item_attrs(inherited_item).stringify_keys) + own_attrs = strip_string_values(item.model.attributes.slice(*inherited_attrs.keys).stringify_keys) if own_attrs == inherited_attrs Rails.logger.info( @@ -60,6 +60,11 @@ def export_content_from_adapter(storage_adapter) # rubocop:disable Metrics/Metho end end + # (attributes include booleans and so on as well as text, and only the text has whitespace to ignore) + def strip_string_values(attrs) + attrs.transform_values { |value| value.is_a?(String) ? value.strip : value } + end + def export_metadata File.open(File.expand_path("metadata.yml", content_set.root_path), "w") do |f| metadata = { @@ -95,11 +100,11 @@ def serialize_navigation_items(items) end def serialize_variables - cms_parent.cms_variables.order(:key).each_with_object({}) { |variable, hash| hash[variable.key] = variable.value } + cms_parent.cms_variables.order(:key).to_h { |variable| [variable.key, variable.value] } end def inherited_content_sets - @inherit_content_sets ||= inherit.map { |content_set_name| CmsContentSet.new(name: content_set_name) } + @inherited_content_sets ||= inherit.map { |content_set_name| CmsContentSet.new(name: content_set_name) } end def ensure_no_conflicting_folder diff --git a/test/controllers/calendars_controller_test.rb b/test/controllers/calendars_controller_test.rb new file mode 100644 index 00000000000..a15bda7776f --- /dev/null +++ b/test/controllers/calendars_controller_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true +require "test_helper" + +describe CalendarsController do + let(:convention) { create(:convention, name: "Test Con", timezone_name: "America/New_York") } + let(:user_con_profile) do + create(:user_con_profile, convention:, user: create(:user, first_name: "Alice", last_name: "Attendee")) + end + + setup { set_convention convention } + + def get_schedule(profile = user_con_profile, secret: profile.ical_secret) + get :user_schedule, params: { id: secret } + response + end + + def signup_to( + event_title, + state: "confirmed", + title_suffix: nil, + rooms: [], + starts_at: convention.starts_at + 2.hours + ) + event = create(:event, convention:, title: event_title, short_blurb: "About #{event_title}") + run = create(:run, event:, starts_at:, title_suffix:) + rooms.each { |name| run.rooms << create(:room, convention:, name:) } + create(:signup, run:, user_con_profile:, state:, counted: state == "confirmed") + end + + def calendar + Icalendar::Calendar.parse(response.body).first + end + + describe "GET user_schedule" do + it "serves a calendar" do + get_schedule + + assert_response :success + assert_equal "text/calendar", response.media_type + assert_not_nil calendar + end + + it "names the calendar for the convention and the attendee" do + get_schedule + + assert_equal "Test Con Schedule for Alice Attendee", calendar.x_wr_calname.first.to_s + end + + it "includes the convention's timezone" do + get_schedule + + assert_equal(["America/New_York"], calendar.timezones.map { |timezone| timezone.tzid.to_s }) + end + + it "has an event for each signup" do + signup_to "First Game" + signup_to "Second Game" + + get_schedule + + assert_equal ["First Game", "Second Game"], calendar.events.map { |event| event.summary.to_s }.sort + end + + it "describes each event with its time, description and link to the event page" do + signup = signup_to("First Game", starts_at: Time.utc(2016, 10, 28, 20, 0, 0)) + + get_schedule + + event = calendar.events.first + assert_equal signup.run.starts_at, event.dtstart.utc + assert_equal signup.run.ends_at, event.dtend.utc + assert_equal "America/New_York", event.dtstart.ical_params["tzid"].first + assert_equal "About First Game", event.description.to_s + assert_includes event.url.to_s, "/events/#{signup.event.to_param}" + assert_includes event.url.to_s, convention.domain + end + + it "marks waitlisted signups" do + signup_to "Full Game", state: "waitlisted" + + get_schedule + + assert_equal "[WAITLISTED] Full Game", calendar.events.first.summary.to_s + end + + it "adds a run's title suffix to the summary" do + signup_to "Repeat Game", title_suffix: "Late Night" + + get_schedule + + assert_equal "Repeat Game (Late Night)", calendar.events.first.summary.to_s + end + + it "lists the run's rooms" do + signup_to "Roomy Game", rooms: %w[Salon Ballroom] + + get_schedule + + assert_equal %w[Ballroom Salon], + Array(calendar.events.first.location) + .flatten + .map(&:to_s) + .flat_map { |l| l.split(",").map(&:strip) } + .sort + end + + it "leaves out signups that were withdrawn" do + signup_to "Dropped Game", state: "withdrawn" + signup_to "Kept Game" + + get_schedule + + assert_equal(["Kept Game"], calendar.events.map { |event| event.summary.to_s }) + end + + it "does not include other attendees' signups" do + other = create(:user_con_profile, convention:) + event = create(:event, convention:, title: "Somebody Else's Game") + create(:signup, run: create(:run, event:), user_con_profile: other) + + get_schedule + + assert_empty calendar.events + end + + it "is an empty calendar for someone with no signups" do + get_schedule + + assert_response :success + assert_empty calendar.events + end + + it "does not need a login, just the secret in the address" do + get_schedule + + assert_response :success + end + + it "is not found with the wrong secret" do + assert_raises(ActiveRecord::RecordNotFound) { get_schedule(secret: "not-the-secret") } + end + + it "is not found at another convention with this attendee's secret" do + other_convention = create(:convention) + set_convention other_convention + + assert_raises(ActiveRecord::RecordNotFound) { get_schedule } + end + end +end diff --git a/test/models/search_result_test.rb b/test/models/search_result_test.rb new file mode 100644 index 00000000000..54c7587329f --- /dev/null +++ b/test/models/search_result_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true +require "test_helper" +require_relative "../policies/convention_permissions_test_helper" + +class SearchResultTest < ActiveSupport::TestCase + include ConventionPermissionsTestHelper + + let(:convention) { create(:convention) } + let(:anonymous) { AuthorizationInfo.new(nil, nil) } + let(:admin) { AuthorizationInfo.new(create(:site_admin), nil) } + + def search(query, as: anonymous, convention_id: convention.id, **) + SearchResult.full_text_site_search(query, convention_id, as, **) + end + + def titles(result) + result.entries.map(&:title) + end + + describe ".full_text_site_search" do + it "finds events by what they say" do + create(:event, convention:, title: "Dragon Hunt") + create(:event, convention:, title: "Garden Party") + + assert_equal ["Dragon Hunt"], titles(search("dragon")) + end + + it "finds pages, by name" do + create(:page, parent: convention, name: "Volunteer Information", content: "Please help us") + + assert_equal ["Volunteer Information"], titles(search("volunteer")) + end + + it "searches only the given convention" do + create(:event, convention:, title: "Dragon Hunt") + create(:event, convention: create(:convention), title: "Dragon Chase") + + assert_equal ["Dragon Hunt"], titles(search("dragon")) + end + + it "counts all the matches, even past the limit" do + 3.times { |n| create(:event, convention:, title: "Dragon #{n}") } + + result = search("dragon", limit: 2) + + assert_equal 3, result.total_entries + assert_equal 2, result.entries.size + end + + it "leaves out events that were dropped" do + create(:event, convention:, title: "Dragon Hunt", status: "dropped") + + assert_empty search("dragon").entries + end + + it "leaves out pages hidden from search" do + create(:page, parent: convention, name: "Dragon Secrets", hidden_from_search: true) + + assert_empty search("dragon").entries + end + + it "finds nothing for something that matches nothing" do + create(:event, convention:, title: "Dragon Hunt") + + result = search("unicorn") + + assert_equal 0, result.total_entries + assert_empty result.entries + end + end + + describe "attendees" do + let(:attendee_user) { create(:user, first_name: "Zephyrina", last_name: "Wanderer") } + + before { create(:user_con_profile, convention:, user: attendee_user) } + + it "are hidden from the public" do + assert_empty search("Zephyrina").entries + end + + it "are hidden from other attendees" do + other = create(:user_con_profile, convention:).user + + assert_empty search("Zephyrina", as: AuthorizationInfo.new(other, nil)).entries + end + + it "are found by someone who can read them" do + reader = create_user_with_permission_in_convention("read_user_con_profiles", convention) + + assert_equal ["Zephyrina Wanderer"], titles(search("Zephyrina", as: AuthorizationInfo.new(reader, nil))) + end + + it "are found by site admins" do + assert_equal ["Zephyrina Wanderer"], titles(search("Zephyrina", as: admin)) + end + end + + describe "event proposals" do + let(:proposal) do + create( + :event_proposal, + convention:, + event_category: create(:event_category, convention:), + title: "Quixotic Quest" + ) + end + + before { proposal.update!(status: "proposed") } + + it "are hidden from the public" do + assert_empty search("Quixotic").entries + end + + it "are found by site admins" do + assert_equal ["Quixotic Quest"], titles(search("Quixotic", as: admin)) + end + + it "are hidden again once they are decided" do + proposal.update!(status: "accepted") + + assert_empty search("Quixotic", as: admin).entries + end + end + + describe SearchResult::Entry do + it "knows its model and how to describe it" do + event = create(:event, convention:, title: "Dragon Hunt") + + entry = search("dragon").entries.first + + assert_equal event, entry.model + assert_equal "Event", entry.model_type + assert_equal "Dragon Hunt", entry.title + end + + it "carries the highlight and rank from the search" do + create(:event, convention:, title: "Dragon Hunt") + + entry = search("dragon").entries.first + + assert_includes entry.highlight, "Dragon" + assert_kind_of Float, entry.rank.to_f + end + + it "titles a page by its name and an attendee by their name without nickname" do + create(:page, parent: convention, name: "Gallery Hours") + create( + :user_con_profile, + convention:, + user: create(:user, first_name: "Quentin", last_name: "Zimmer"), + nickname: "Q" + ) + + assert_equal ["Gallery Hours"], titles(search("gallery")) + assert_equal ["Quentin Zimmer"], titles(search("Quentin", as: admin)) + end + + it "falls back to the type and id for other kinds of thing" do + entry = SearchResult::Entry.new(Struct.new(:searchable).new(create(:room, convention:))) + + assert_match(/\ARoom \d+\z/, entry.title) + end + end +end diff --git a/test/services/cms_content_loaders/conflict_policies/update_test.rb b/test/services/cms_content_loaders/conflict_policies/update_test.rb new file mode 100644 index 00000000000..c07010c0c01 --- /dev/null +++ b/test/services/cms_content_loaders/conflict_policies/update_test.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true +require "test_helper" + +class CmsContentLoaders::ConflictPolicies::UpdateTest < ActiveSupport::TestCase + Policy = CmsContentLoaders::ConflictPolicies::Update + Item = Struct.new(:identifier, :model) + Model = Struct.new(:content, :name, :skip_clickwrap_agreement) + + # The existing item is what's in the database now, the previous content is what the last load put there, and the + # new attributes are what's about to be loaded + def action(previous:, existing:, incoming:, identifier: "page") + policy = Policy.new({ identifier => previous }) + action = policy.action_for(Item.new(identifier, existing), Item.new(identifier, nil), incoming) + [action, policy] + end + + describe "#action_for" do + it "overwrites an item that has not been touched since it was loaded" do + result, = + action(previous: { "content" => "Original" }, existing: Model.new("Original"), incoming: { "content" => "New" }) + + assert_equal :overwrite, result + end + + it "skips an item that has been edited since it was loaded" do + result, = + action(previous: { "content" => "Original" }, existing: Model.new("Edited"), incoming: { "content" => "New" }) + + assert_equal :skip, result + end + + it "overwrites an item that was edited to match what is about to be loaded" do + result, = + action(previous: { "content" => "Original" }, existing: Model.new("New"), incoming: { "content" => "New" }) + + assert_equal :overwrite, result + end + + it "ignores whitespace around values" do + result, = + action( + previous: { + "content" => "Original\n" + }, + existing: Model.new(" Original "), + incoming: { + "content" => "New" + } + ) + + assert_equal :overwrite, result + end + + it "understands symbol keys" do + result, = action(previous: { content: "Original" }, existing: Model.new("Original"), incoming: { content: "New" }) + + assert_equal :overwrite, result + end + + it "skips if any of the attributes was edited" do + result, = + action( + previous: { + "content" => "Original", + "name" => "Page" + }, + existing: Model.new("Original", "Renamed"), + incoming: { + "content" => "New", + "name" => "Page" + } + ) + + assert_equal :skip, result + end + + it "copes with attributes that are not text" do + result, = + action( + previous: { + "content" => "Original", + "skip_clickwrap_agreement" => false + }, + existing: Model.new("Original", "Page", false), + incoming: { + "content" => "New", + "skip_clickwrap_agreement" => false + } + ) + + assert_equal :overwrite, result + end + + it "skips an item whose non-text attribute was changed" do + result, = + action( + previous: { + "content" => "Original", + "skip_clickwrap_agreement" => false + }, + existing: Model.new("Original", "Page", true), + incoming: { + "content" => "New", + "skip_clickwrap_agreement" => false + } + ) + + assert_equal :skip, result + end + + it "treats an item with no model as unmodified" do + result, = action(previous: { "content" => "Original" }, existing: nil, incoming: { "content" => "New" }) + + assert_equal :overwrite, result + end + end + + describe "what is skipped" do + it "is recorded with the item and why" do + _result, policy = + action(previous: { "content" => "Original" }, existing: Model.new("Edited"), incoming: { "content" => "New" }) + + assert_equal 1, policy.skipped_items.size + skipped = policy.skipped_items.first + assert_equal "page", skipped.identifier + assert_equal({ "content" => "New" }, skipped.attrs) + assert_equal("skipped #{Model.name} page because content has been modified", skipped.message) + end + + it "names every modified attribute, pluralizing correctly" do + _result, policy = + action( + previous: { + "content" => "Original", + "name" => "Page" + }, + existing: Model.new("Edited", "Renamed"), + incoming: { + "content" => "New", + "name" => "Page" + } + ) + + assert_match(/content and name have been modified/, policy.skipped_items.first.message) + end + + it "records nothing for items that are overwritten" do + _result, policy = + action(previous: { "content" => "Original" }, existing: Model.new("Original"), incoming: { "content" => "New" }) + + assert_empty policy.skipped_items + end + end + + describe "items that were not in the previous load" do + it "treats every attribute the previous load had for other items as one it left blank" do + policy = Policy.new({ "other" => { "content" => "x" } }) + + # (nothing was loaded for this item last time, so anything now in the database was put there by someone else) + assert_equal :skip, + policy.action_for( + Item.new("new_page", Model.new("Someone's work")), + Item.new("new_page"), + { "content" => "New" } + ) + end + + it "overwrites ones that are blank in the database" do + policy = Policy.new({ "other" => { "content" => "x" } }) + + assert_equal :overwrite, + policy.action_for(Item.new("new_page", Model.new(nil)), Item.new("new_page"), { "content" => "New" }) + end + end +end diff --git a/test/services/export_cms_content_set_service_test.rb b/test/services/export_cms_content_set_service_test.rb new file mode 100644 index 00000000000..4096bea8502 --- /dev/null +++ b/test/services/export_cms_content_set_service_test.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true +require "test_helper" + +class ExportCmsContentSetServiceTest < ActiveSupport::TestCase + # Exports go to a temporary directory instead of the app's cms_content_sets folder (there is no `around` here, so + # the whole run of each test is wrapped instead) + def run(...) + Dir.mktmpdir do |dir| + @root = dir + CmsContentSet.stub(:root_path, dir) { super } + end + end + + let(:convention) { create(:convention) } + + def export(name = "exported", inherit: [], **) + ExportCmsContentSetService.new(convention:, content_set_name: name, inherit:, **).call + end + + def exported_path(*parts) + File.join(@root, "exported", *parts) + end + + def metadata + YAML.safe_load_file(exported_path("metadata.yml")) + end + + describe "validation" do + it "needs a name for the content set" do + result = export("") + + assert_not result.success? + assert_includes result.errors.full_messages.join, "Content set name" + end + + it "refuses to overwrite a folder that already exists" do + FileUtils.mkdir_p(File.join(@root, "exported")) + + result = export + + assert_not result.success? + assert_includes result.errors.full_messages.join, "already exists" + end + end + + describe "exporting a convention" do + it "creates the content set's folder and a metadata file" do + result = export + + assert result.success? + assert File.directory?(exported_path) + assert File.exist?(exported_path("metadata.yml")) + end + + it "records what the content set inherits from" do + export(inherit: []) + + assert_equal [], metadata["inherit"] + end + + it "exports the root page and default layout by name" do + layout = create(:cms_layout, parent: convention, name: "Main layout") + page = create(:page, parent: convention, name: "Welcome", slug: "welcome", cms_layout: layout) + convention.update!(root_page: page, default_layout: layout) + + export + + assert_equal "welcome", metadata["root_page_slug"] + assert_equal "Main layout", metadata["default_layout_name"] + end + + it "leaves out the root page and layout when the convention has none" do + export + + assert_not metadata.key?("root_page_slug") + assert_not metadata.key?("default_layout_name") + end + + it "exports variables in key order" do + create(:cms_variable, parent: convention, key: "zebra", value: "z") + create(:cms_variable, parent: convention, key: "apple", value: "a") + + export + + assert_equal({ "apple" => "a", "zebra" => "z" }, metadata["variables"]) + assert_equal %w[apple zebra], metadata["variables"].keys + end + + it "exports navigation items, nested, in position order" do + page = create(:page, parent: convention, slug: "about") + section = create(:cms_navigation_item, parent: convention, title: "Section", position: 2) + create(:cms_navigation_item, parent: convention, title: "First", position: 1, page:) + create(:cms_navigation_item, parent: convention, title: "Nested", navigation_section: section, position: 1) + + export + + titles = metadata["navigation_items"].pluck("title") + assert_equal %w[First Section], titles + assert_equal "about", metadata["navigation_items"].first["page_slug"] + assert_equal(["Nested"], metadata["navigation_items"].last["navigation_links"].pluck("title")) + end + + it "writes pages, partials and layouts to their folders" do + create(:page, parent: convention, name: "About", slug: "about", content: "About us") + create(:cms_partial, parent: convention, name: "footer", content: "Goodbye") + create(:cms_layout, parent: convention, name: "Main", content: "{{ content }}") + + export + + assert_includes File.read(exported_path("pages", "about.liquid")), "About us" + assert_includes File.read(exported_path("partials", "footer.liquid")), "Goodbye" + assert_equal 1, Dir.glob(exported_path("layouts", "*")).size + end + + it "exports the site's own content for a convention-less (root site) export" do + create(:root_site).pages.create!(name: "Root page", slug: "root-page", content: "Root!") + + ExportCmsContentSetService.new(convention: nil, content_set_name: "exported", inherit: []).call! + + assert_includes File.read(exported_path("pages", "root-page.liquid")), "Root!" + end + end + + describe "inheriting" do + before do + create(:page, parent: convention, name: "Same", slug: "same", content: "Unchanged content") + create(:page, parent: convention, name: "Changed", slug: "changed", content: "Original content") + ExportCmsContentSetService.new(convention:, content_set_name: "base", inherit: []).call! + convention.pages.find_by!(slug: "changed").update!(content: "Edited content") + convention.reload + end + + it "leaves out items that are the same as the inherited version" do + export("exported", inherit: ["base"]) + + assert_not File.exist?(exported_path("pages", "same.liquid")) + end + + it "keeps items that differ from the inherited version" do + export("exported", inherit: ["base"]) + + assert_includes File.read(exported_path("pages", "changed.liquid")), "Edited content" + end + + it "ignores whitespace around attribute values when comparing" do + convention.pages.find_by!(slug: "same").update!(content: " Unchanged content \n") + convention.reload + + export("exported", inherit: ["base"]) + + assert_not File.exist?(exported_path("pages", "same.liquid")) + end + + it "records the inherited sets in the metadata" do + export("exported", inherit: ["base"]) + + assert_equal ["base"], metadata["inherit"] + end + + it "exports everything when nothing is inherited" do + export("exported", inherit: []) + + assert File.exist?(exported_path("pages", "same.liquid")) + assert File.exist?(exported_path("pages", "changed.liquid")) + end + end + + describe "round trip" do + it "can be loaded into another convention" do + create(:page, parent: convention, name: "About", slug: "about", content: "About us") + create(:cms_partial, parent: convention, name: "footer", content: "Goodbye") + create(:cms_variable, parent: convention, key: "greeting", value: "hello") + export + + other = create(:convention) + other.forms.destroy_all + other.update!(user_con_profile_form: nil) + LoadCmsContentSetService.new(convention: other, content_set_name: "exported").call! + + assert_equal "About us", other.pages.find_by!(slug: "about").content.strip + assert_equal "Goodbye", other.cms_partials.find_by!(name: "footer").content.strip + assert_equal "hello", other.cms_variables.find_by!(key: "greeting").value + end + end +end diff --git a/test/services/setup_user_con_profile_service_test.rb b/test/services/setup_user_con_profile_service_test.rb new file mode 100644 index 00000000000..ad5207bddf2 --- /dev/null +++ b/test/services/setup_user_con_profile_service_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true +require "test_helper" + +class SetupUserConProfileServiceTest < ActiveSupport::TestCase + let(:organization) { create(:organization) } + let(:convention) { create(:convention, :with_standard_content, organization:) } + let(:user) { create(:user, first_name: "Newcomer", last_name: "Attendee") } + + def earlier_convention(years_ago:, **attributes) + create( + :convention, + :with_standard_content, + organization:, + starts_at: years_ago.years.ago, + ends_at: years_ago.years.ago + 3.days, + **attributes + ) + end + + def setup_profile(convention: self.convention) + SetupUserConProfileService.new(convention:, user:).call!.user_con_profile + end + + describe "a new profile" do + it "is saved, for the user at the convention, with the user's name" do + profile = setup_profile + + assert profile.persisted? + assert_equal user, profile.user + assert_equal convention, profile.convention + assert_equal "Newcomer", profile.first_name + assert_equal "Attendee", profile.last_name + end + + it "is marked as needing an update, so the attendee is asked to check it" do + assert setup_profile.needs_update + end + + it "takes default values from the profile form" do + profile = setup_profile + + # (the standard form defaults these two to true) + assert profile.allow_sms + assert profile.receive_whos_free_emails + end + + it "returns a successful result with the profile" do + result = SetupUserConProfileService.new(convention:, user:).call + + assert result.success? + assert_kind_of UserConProfile, result.user_con_profile + end + + it "fails if the user already has a profile at the convention" do + create(:user_con_profile, convention:, user:) + + result = SetupUserConProfileService.new(convention:, user:).call + + assert_not result.success? + end + end + + describe "copying from earlier conventions of the same organization" do + it "brings over the details that the new profile form asks for" do + create( + :user_con_profile, + convention: earlier_convention(years_ago: 2), + user:, + city: "Providence", + state: "RI", + mobile_phone: "401-555-0100" + ) + + profile = setup_profile + + assert_equal "Providence", profile.city + assert_equal "RI", profile.state + assert_equal "401-555-0100", profile.mobile_phone + end + + it "brings over whether the attendee uses Gravatar" do + create(:user_con_profile, convention: earlier_convention(years_ago: 2), user:, gravatar_enabled: true) + + assert setup_profile.gravatar_enabled + end + + it "prefers the most recent convention when several have a value" do + create(:user_con_profile, convention: earlier_convention(years_ago: 4), user:, city: "Old City") + create(:user_con_profile, convention: earlier_convention(years_ago: 2), user:, city: "Newer City") + + assert_equal "Newer City", setup_profile.city + end + + it "still marks the copied profile as needing an update" do + create(:user_con_profile, convention: earlier_convention(years_ago: 2), user:, city: "Providence") + + assert setup_profile.needs_update + end + + it "does not copy from conventions of another organization" do + other_organization_convention = + create( + :convention, + :with_standard_content, + organization: create(:organization), + starts_at: 2.years.ago, + ends_at: 2.years.ago + 3.days + ) + create(:user_con_profile, convention: other_organization_convention, user:, city: "Elsewhere") + + assert_nil setup_profile.city + end + + it "does not copy from other people's profiles" do + create(:user_con_profile, convention: earlier_convention(years_ago: 2), city: "Somebody Else's City") + + assert_nil setup_profile.city + end + + it "copies nothing for a convention that is not part of an organization" do + standalone = create(:convention, :with_standard_content, organization: nil) + create(:user_con_profile, convention: earlier_convention(years_ago: 2), user:, city: "Providence") + + assert_nil setup_profile(convention: standalone).city + end + + it "copies nothing the first time" do + assert_nil setup_profile.city + end + end +end