diff --git a/Gemfile.lock b/Gemfile.lock index 03fbf59a7..fc91f9df1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,10 +103,10 @@ GEM aes_key_wrap (1.1.0) ast (2.4.2) aws-eventstream (1.3.0) - aws-partitions (1.1041.0) + aws-partitions (1.1043.0) aws-record (2.13.2) aws-sdk-dynamodb (~> 1, >= 1.85.0) - aws-sdk-core (3.216.0) + aws-sdk-core (3.217.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -171,7 +171,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.0) racc builder (3.3.0) bullet (8.0.0) @@ -225,7 +225,7 @@ GEM docile (1.4.1) dotenv (3.1.7) drb (2.2.1) - dumb_delegator (1.0.0) + dumb_delegator (1.1.0) erubi (1.13.1) excon (1.2.3) factory_bot (6.5.0) @@ -319,7 +319,7 @@ GEM kaminari-core (1.2.2) kramdown (2.5.1) rexml (>= 3.3.9) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -421,7 +421,7 @@ GEM date stringio public_suffix (6.0.1) - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) rack (3.1.8) @@ -520,7 +520,7 @@ GEM rspec-support (3.13.2) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.70.0) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -530,9 +530,9 @@ GEM rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) - rubocop-rails (2.29.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) diff --git a/app/controllers/admin/cx_collection_details_controller.rb b/app/controllers/admin/cx_collection_details_controller.rb index 4a907e2bc..7ab54b0fe 100644 --- a/app/controllers/admin/cx_collection_details_controller.rb +++ b/app/controllers/admin/cx_collection_details_controller.rb @@ -74,41 +74,44 @@ def export_csv # Handle a large-ish csv upload (5+ MB) to S3 def upload_csv file = params[:file] # Assuming the file comes from a form field named 'file' - file_extension = File.extname(file.original_filename).downcase - @valid_file_extension = (file_extension == ".csv") - # check the file to ensure it is valid - csv_file = CSV.parse(file.read, headers: true) - begin - @valid_file_headers = csv_file.headers.sort == [ - "external_id", - "question_1", - "positive_effectiveness", - "positive_ease", - "positive_efficiency", - "positive_transparency", - "positive_humanity", - "positive_employee", - "positive_other", - "negative_effectiveness", - "negative_ease", - "negative_efficiency", - "negative_transparency", - "negative_humanity", - "negative_employee", - "negative_other", - "question_4" - ].sort - rescue CSV::MalformedCSVError => e - flash[:alert] = "There was an error processing the CSV file: #{e.message}" - @valid_file_headers = false - rescue - @valid_file_headers = false + @valid_file_encoding = false + file_contents = file.read.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") + @valid_file_encoding = file_contents.encoding.name == "UTF-8" && file_contents.valid_encoding? + + if @valid_file_encoding + csv_file = CSV.parse(file_contents, headers: true) + begin + @valid_file_headers = csv_file.headers.sort == [ + "external_id", + "question_1", + "positive_effectiveness", + "positive_ease", + "positive_efficiency", + "positive_transparency", + "positive_humanity", + "positive_employee", + "positive_other", + "negative_effectiveness", + "negative_ease", + "negative_efficiency", + "negative_transparency", + "negative_humanity", + "negative_employee", + "negative_other", + "question_4" + ].sort + rescue CSV::MalformedCSVError => e + flash[:alert] = "There was an error processing the CSV file: #{e.message}" + @valid_file_headers = false + rescue + @valid_file_headers = false + end end - if @valid_file_extension && @valid_file_headers + if @valid_file_encoding && @valid_file_extension && @valid_file_headers key = "cx_data_collections/cx-upload-#{timestamp_string}-#{file.original_filename}" obj = s3_bucket.object(key) # Upload the file @@ -124,13 +127,14 @@ def upload_csv Event.log_event(Event.names[:cx_collection_detail_upload_created], @cxdu.class.to_s, @cxdu.id, "CX Collection Detail Upload #{@cxdu.id} created at #{DateTime.now}", current_user.id) flash[:notice] = "A .csv file with #{csv_file.size} rows was uploaded successfully. Please see your uploaded file in the table below, then return to the CX Data Collection." + elsif !@valid_file_encoding + flash[:alert] = "The uploaded file must be encoded as UTF-8" elsif !@valid_file_extension flash[:notice] = "File has a file extension of #{file_extension}, but it should be .csv." elsif !@valid_file_headers flash[:alert] = "CSV headers do not match. Headers received were: #{csv_file.headers.to_s}" end - # render :upload redirect_to upload_admin_cx_collection_detail_path(@cx_collection_detail) end diff --git a/app/controllers/admin/forms_controller.rb b/app/controllers/admin/forms_controller.rb index 446cd0a2a..708cc939a 100644 --- a/app/controllers/admin/forms_controller.rb +++ b/app/controllers/admin/forms_controller.rb @@ -184,8 +184,8 @@ def show end def export - start_date = params[:start_date] ? Date.parse(params[:start_date]).to_date : Time.zone.now.beginning_of_quarter - end_date = params[:end_date] ? Date.parse(params[:end_date]).to_date : Time.zone.now.end_of_quarter + start_date = params[:start_date] ? Date.parse(params[:start_date]).to_date.beginning_of_day : Time.zone.now.beginning_of_quarter + end_date = params[:end_date] ? Date.parse(params[:end_date]).to_date.end_of_day : Time.zone.now.end_of_quarter count = Form.find_by_short_uuid(@form.short_uuid).non_flagged_submissions(start_date:, end_date:).count if count > MAX_ROWS_TO_EXPORT @@ -593,6 +593,7 @@ def form_admin_options_params :service_id, :service_stage_id, :enforce_new_submission_validations, + :legacy_link_feature_flag, ) end diff --git a/app/controllers/admin/offerings_controller.rb b/app/controllers/admin/offerings_controller.rb deleted file mode 100644 index 437da2f0b..000000000 --- a/app/controllers/admin/offerings_controller.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module Admin - class OfferingsController < AdminController - before_action :set_offering, only: %i[show edit update destroy add_offering_persona remove_offering_persona] - - before_action :set_offering_persona_options, only: %i[ - new - create - edit - update - ] - - def index - @offerings = Offering.order(:name) - end - - def show; end - - def new - @offering = Offering.new - end - - def edit; end - - def create - @offering = Offering.new(offering_params) - - if @offering.save - redirect_to admin_offering_path(@offering), notice: 'Offering was successfully created.' - else - render :new - end - end - - def update - if @offering.update(offering_params) - redirect_to admin_offering_path(@offering), notice: 'Offering was successfully updated.' - else - render :edit - end - end - - def destroy - @offering.destroy - redirect_to admin_offerings_url, notice: 'Offering was successfully destroyed.' - end - - def add_offering_persona - @offering.persona_list.add(params[:persona_id]) - @offering.save - set_offering_persona_options - end - - def remove_offering_persona - @offering.persona_list.remove(params[:persona_id]) - @offering.save - set_offering_persona_options - end - - private - - def set_offering - @offering = Offering.find(params[:id]) - end - - def offering_params - params.require(:offering).permit(:name, :service_id) - end - - def set_offering_persona_options - @offering_persona_options = Persona.order(:name) - @offering_persona_options -= @offering.offering_personas if @offering_persona_options && @offering - end - end -end diff --git a/app/helpers/admin/offerings_helper.rb b/app/helpers/admin/offerings_helper.rb deleted file mode 100644 index 74969c5c1..000000000 --- a/app/helpers/admin/offerings_helper.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module Admin - module OfferingsHelper - end -end diff --git a/app/models/form.rb b/app/models/form.rb index 3d28e3d4d..fac749083 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -227,6 +227,7 @@ def duplicate!(new_user:) new_form.organization = new_user.organization new_form.template = false new_form.enforce_new_submission_validations = true + new_form.legacy_link_feature_flag = false new_form.save! # Manually remove the Form Section created with create_first_form_section diff --git a/app/models/offering.rb b/app/models/offering.rb deleted file mode 100644 index e939ed708..000000000 --- a/app/models/offering.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class Offering < ApplicationRecord - belongs_to :service, optional: true - - acts_as_taggable_on :personas - - has_paper_trail - - def offering_personas - Persona.where(id: persona_list) - end -end diff --git a/app/views/admin/forms/_admin_options.html.erb b/app/views/admin/forms/_admin_options.html.erb index 0aaddfa11..21b224fdf 100644 --- a/app/views/admin/forms/_admin_options.html.erb +++ b/app/views/admin/forms/_admin_options.html.erb @@ -66,6 +66,15 @@ <% end %> +
+ legacy_link_feature_flag +
+ <%= f.check_box :legacy_link_feature_flag, class: "usa-checkbox__input" %> + <%= f.label :legacy_link_feature_flag, class: "usa-checkbox__label" do %> + legacy_link_feature_flag + <% end %> +
+

diff --git a/app/views/admin/offerings/_form.html.erb b/app/views/admin/offerings/_form.html.erb deleted file mode 100644 index cd8d70c43..000000000 --- a/app/views/admin/offerings/_form.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= form_with(model: offering, url: (offering.persisted? ? admin_offering_path(offering) : admin_offerings_path), local: true) do |form| %> - <%- if offering.errors.any? %> -

-

<%= pluralize(offering.errors.count, "error") %> prohibited this offering from being saved:

- - -
- <% end %> - -
- <%= form.label :name, class: "usa-label" %> - <%= form.text_field :name, class: "usa-input" %> -
-
- <%= form.label :service_id, "Service", class: "usa-label" %> - <%= form.select :service_id, options_for_select(Service.all.includes(:organization).order("organizations.name", "services.name").map { |s| ["#{s.organization.name} - #{s.name}", s.id] }, offering.try(:service_id) ), { prompt: "Which Service?" }, { class: "usa-select" } %> -
- - <%- if offering.persisted? %> -
-
-
- <%= render 'admin/offerings/offering_personas', offering: offering, offering_persona_options: offering_persona_options %> -
-
-
- <% end %> - -

- <%= form.submit class: "usa-button" %> -

-<% end %> diff --git a/app/views/admin/offerings/_offering_personas.html.erb b/app/views/admin/offerings/_offering_personas.html.erb deleted file mode 100644 index 47f7a55ac..000000000 --- a/app/views/admin/offerings/_offering_personas.html.erb +++ /dev/null @@ -1,60 +0,0 @@ -
-
-

- Personas -

- - Optionally, this offering may be associated with one or more - <%= link_to "Personas", admin_personas_path %>. - -

-

-

-
-
- -<%= select_tag :offering_persona_id, options_for_select(offering_persona_options.map { |p| [p.name, p.id] }), prompt: "Choose a Persona", class: "usa-select add-offering-persona" %> - - diff --git a/app/views/admin/offerings/add_offering_persona.js.erb b/app/views/admin/offerings/add_offering_persona.js.erb deleted file mode 100644 index ab8618143..000000000 --- a/app/views/admin/offerings/add_offering_persona.js.erb +++ /dev/null @@ -1 +0,0 @@ -$(".offering-personas-div").html("<%= escape_javascript render(:partial => 'admin/offerings/offering_personas', :locals => { :offering => @offering }) %>"); diff --git a/app/views/admin/offerings/edit.html.erb b/app/views/admin/offerings/edit.html.erb deleted file mode 100644 index 5c205006e..000000000 --- a/app/views/admin/offerings/edit.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<% content_for :navigation_title do %> - Editing Offering -<% end %> -

- <%= link_to admin_offering_path(@offering) do %> - - Back to Offering - <% end %> -

- -<%= render 'form', offering: @offering, offering_persona_options: @offering_persona_options %> - -

- <%= link_to admin_offering_path(@offering), method: :delete, data: { confirm: 'Are you sure?' }, class: "usa-button usa-button--secondary" do %> - - Delete - <% end %> -

diff --git a/app/views/admin/offerings/index.html.erb b/app/views/admin/offerings/index.html.erb deleted file mode 100644 index b3ba5934c..000000000 --- a/app/views/admin/offerings/index.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -<% content_for :navigation_title do %> - Offerings - <%= link_to new_admin_offering_path, class: "usa-button usa-button-inverted float-right" do %> - - New Offering - <% end %> -<% end %> - - - - - - - - - - - <% @offerings.each do |offering| %> - - - - - <% end %> - -
NameService
<%= link_to offering.name, admin_offering_path(offering) %><%= offering.service.name if offering.service.present? %>
diff --git a/app/views/admin/offerings/new.html.erb b/app/views/admin/offerings/new.html.erb deleted file mode 100644 index 504ebd588..000000000 --- a/app/views/admin/offerings/new.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% content_for :navigation_title do %> - New Offering -<% end %> -

- <%= link_to admin_offerings_path do %> - - Back to Offerings - <% end %> -

- -<%= render 'form', offering: @offering, offering_persona_options: @offering_persona_options %> diff --git a/app/views/admin/offerings/remove_offering_persona.js.erb b/app/views/admin/offerings/remove_offering_persona.js.erb deleted file mode 100644 index ab8618143..000000000 --- a/app/views/admin/offerings/remove_offering_persona.js.erb +++ /dev/null @@ -1 +0,0 @@ -$(".offering-personas-div").html("<%= escape_javascript render(:partial => 'admin/offerings/offering_personas', :locals => { :offering => @offering }) %>"); diff --git a/app/views/admin/offerings/show.html.erb b/app/views/admin/offerings/show.html.erb deleted file mode 100644 index 82dc6b4ba..000000000 --- a/app/views/admin/offerings/show.html.erb +++ /dev/null @@ -1,35 +0,0 @@ -<% content_for :navigation_title do %> - Viewing Offering: <%= @offering.name %> - <%= link_to edit_admin_offering_path(@offering), class: "usa-button usa-button-inverted float-right" do %> - - Edit - <% end %> -<% end %> -

- <%= link_to admin_offerings_path do %> - - Back to Offerings - <% end %> -

- -

- Name -
- <%= @offering.name %> -

- -

- Service -
- <%- if @offering.service %> - <%= link_to @offering.service.name, admin_service_path(@offering.service) if @offering.service %> - <% else %> -

-
-

- This Offering is not currently associated with a Service. -

-
-
- <% end %> -

diff --git a/app/views/components/widget/_fba.js.erb b/app/views/components/widget/_fba.js.erb index 0840da004..ebefad59d 100644 --- a/app/views/components/widget/_fba.js.erb +++ b/app/views/components/widget/_fba.js.erb @@ -369,12 +369,18 @@ function FBAform(d, N) { this.landmarkElement.setAttribute('role', 'complementary'); // Add the fixed, floating tab button - this.buttonEl = d.createElement('a'); + <%# FEATURE FLAG %> + if(this.options.legacyLink) { + this.buttonEl = d.createElement('a'); + this.buttonEl.setAttribute('href', 'javascript:void(0)'); + } else { + this.buttonEl = d.createElement('button'); + } this.buttonEl.setAttribute('id', 'fba-button'); this.buttonEl.setAttribute('data-id', this.options.formId); this.buttonEl.setAttribute('class', 'fba-button fixed-tab-button usa-button'); this.buttonEl.setAttribute('name', 'fba-button'); - this.buttonEl.setAttribute('href', 'javascript:void(0)'); + this.buttonEl.setAttribute('aria-label', this.options.modalButtonText); this.buttonEl.setAttribute('aria-haspopup', 'dialog'); this.buttonEl.setAttribute('aria-controls', this.modalId()); this.buttonEl.setAttribute('data-open-modal', ''); @@ -639,6 +645,7 @@ function FBAform(d, N) { // Specify the options for your form const touchpointFormOptions<%= form.short_uuid %> = { + 'legacyLink': <%= form.legacy_link_feature_flag? %>, 'formId': "<%= form.short_uuid %>", 'modalButtonText': "<%= form.modal_button_text %>", 'elementSelector': "<%= form.element_selector %>", diff --git a/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb b/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb new file mode 100644 index 000000000..1997e40f7 --- /dev/null +++ b/db/migrate/20250130195535_add_forms_legacy_link_feature_flag.rb @@ -0,0 +1,7 @@ +class AddFormsLegacyLinkFeatureFlag < ActiveRecord::Migration[7.2] + def change + add_column :forms, :legacy_link_feature_flag, :boolean, default: false, comment: "when true, render fba-button as an A, otherwise render as BUTTON" + + Form.update_all(legacy_link_feature_flag: false) + end +end diff --git a/db/migrate/20250206005216_rm_offerings.rb b/db/migrate/20250206005216_rm_offerings.rb new file mode 100644 index 000000000..06f186878 --- /dev/null +++ b/db/migrate/20250206005216_rm_offerings.rb @@ -0,0 +1,5 @@ +class RmOfferings < ActiveRecord::Migration[7.2] + def change + drop_table :offerings + end +end diff --git a/db/schema.rb b/db/schema.rb index a8ac7d849..d067abcda 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.2].define(version: 2025_01_17_004643) do +ActiveRecord::Schema[7.2].define(version: 2025_02_06_005216) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -311,6 +311,7 @@ t.string "short_uuid", limit: 8 t.boolean "enforce_new_submission_validations", default: true t.integer "service_stage_id" + t.boolean "legacy_link_feature_flag", default: false, comment: "when true, render fba-button as an A, otherwise render as BUTTON" t.index ["legacy_touchpoint_id"], name: "index_forms_on_legacy_touchpoint_id" t.index ["legacy_touchpoint_uuid"], name: "index_forms_on_legacy_touchpoint_uuid" t.index ["organization_id"], name: "index_forms_on_organization_id" @@ -351,13 +352,6 @@ t.datetime "updated_at", null: false end - create_table "offerings", force: :cascade do |t| - t.string "name" - t.integer "service_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "omb_cx_reporting_collections", comment: "A detailed record belonging to a Collection; a quarterly CX Data Collection", force: :cascade do |t| t.integer "collection_id" t.string "service_provided" diff --git a/spec/controllers/admin/cx_collection_details_controller_spec.rb b/spec/controllers/admin/cx_collection_details_controller_spec.rb new file mode 100644 index 000000000..657fe50ca --- /dev/null +++ b/spec/controllers/admin/cx_collection_details_controller_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::CxCollectionDetailsController, type: :controller do + let(:organization) { FactoryBot.create(:organization) } + let(:another_organization) { FactoryBot.create(:organization) } + let(:a_third_organization) { FactoryBot.create(:organization) } + let(:admin) { FactoryBot.create(:user, :admin, organization:) } + let(:user) { FactoryBot.create(:user, organization: another_organization) } + let(:user_3) { FactoryBot.create(:user, organization: a_third_organization) } + let(:service) { FactoryBot.create(:service, organization:, service_owner_id: user.id) } + let(:service_provider) { FactoryBot.create(:service_provider, organization:) } + let(:cx_collection) { FactoryBot.create(:cx_collection, organization: another_organization, service: service) } + let(:cx_collection_detail) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) } + + let(:valid_session) { {} } + + let(:valid_attributes) do + FactoryBot.build(:cx_collection, organization:, user: admin, service_provider:).attributes + end + + let(:invalid_attributes) do + { + name: 'Only', + organization_id: nil, + user_id: nil, + } + end + + context 'as a User' do + before do + sign_in(user) + end + + describe 'GET /show' do + it 'renders a successful response' do + get :index, params: {}, session: valid_session + expect(response).to be_successful + end + end + end + + context 'as admin' do + before do + sign_in(admin) + end + + describe 'GET #export_csv' do + let!(:cx_collection_detail2) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) } + + it 'renders a successful response' do + get :export_csv, params: { id: cx_collection_detail.cx_collection_id }, session: valid_session + expect(response).to be_successful + + csv = CSV.parse(response.body, headers: true) + expect(csv.headers).to eq( + [ + "id", + "organization_id", + "organization_abbreviation", + "organization_name", + "cx_collection_id", + "cx_collection_fiscal_year", + "cx_collection_quarter", + "cx_collection_name", + "cx_collection_service_provider_id", + "cx_collection_service_provider_name", + "cx_collection_service_provider_slug", + "service_id", + "service_name", + "transaction_point", + "channel", + "service_stage_id", + "service_stage_name", + "service_stage_position", + "service_stage_count", + "volume_of_customers", + "volume_of_customers_provided_survey_opportunity", + "volume_of_respondents", + "omb_control_number", + "survey_type", + "survey_title", + "trust_question_text", + "created_at", + "updated_at", + ] + ) + expect(csv.size).to eq(2) + end + end + + let(:user) { create(:user) } + let(:cx_collection_detail) { FactoryBot.create(:cx_collection_detail, :with_cx_collection_detail_upload, cx_collection: cx_collection, service: service, transaction_point: :post_service_journey, channel: Service.channels.sample) } + let(:valid_csv) do + <<~CSV + external_id,question_1,positive_effectiveness,positive_ease,positive_efficiency,positive_transparency,positive_humanity,positive_employee,positive_other,negative_effectiveness,negative_ease,negative_efficiency,negative_transparency,negative_humanity,negative_employee,negative_other,question_4 + 123,Yes,1,1,1,1,1,1,1,0,0,0,0,0,0,0,No + CSV + end + + let(:invalid_headers_csv) do + <<~CSV + wrong_header_1,wrong_header_2 + value1,value2 + CSV + end + + before do + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:s3_bucket).and_return(double('S3Bucket', object: double('S3Object', upload_file: true, size: 1000, key: 's3-key'))) + end + + describe 'POST #upload_csv' do + context 'with a valid CSV file' do + it 'uploads successfully and creates a CxCollectionDetailUpload' do + file = File.open("spec/fixtures/sample_cx_responses_upload.csv") + post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id } + expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail)) + expect(flash[:notice]).to match(/A .csv file with \d+ rows was uploaded successfully/) + end + end + + context 'with a valid CSV BOM byte order marked file' do + it 'uploads successfully and creates a CxCollectionDetailUpload ' do + file = File.open("spec/fixtures/sample_cx_responses_upload_with_bom.csv") + post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id } + expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail)) + expect(flash[:notice]).to match(/A .csv file with \d+ rows was uploaded successfully/) + end + end + + context 'with an invalid file extension' do + it 'shows an alert message' do + file = Tempfile.new(['invalid', '.txt']) + file.write(valid_csv) + file.rewind + + post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/plain'), id: cx_collection_detail.id } + + expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail)) + expect(flash[:notice]).to match(/File has a file extension of .txt, but it should be .csv./) + ensure + file.close + file.unlink + end + end + + context 'with invalid CSV headers' do + it 'shows a header mismatch error' do + file = Tempfile.new(['invalid_headers', '.csv']) + file.write(invalid_headers_csv) + file.rewind + + post :upload_csv, params: { file: Rack::Test::UploadedFile.new(file.path, 'text/csv'), id: cx_collection_detail.id } + + expect(response).to redirect_to(upload_admin_cx_collection_detail_path(cx_collection_detail)) + expect(flash[:alert]).to match(/CSV headers do not match/) + ensure + file.close + file.unlink + end + end + end + + end +end diff --git a/spec/controllers/admin/forms_controller_spec.rb b/spec/controllers/admin/forms_controller_spec.rb index 9c1816b9d..68526444b 100644 --- a/spec/controllers/admin/forms_controller_spec.rb +++ b/spec/controllers/admin/forms_controller_spec.rb @@ -30,7 +30,7 @@ # Form. As you add validations to Form, be sure to # adjust the attributes here as well. let(:organization) { FactoryBot.create(:organization) } - let!(:form) { FactoryBot.create(:form, organization: organization) } + let!(:form) { FactoryBot.create(:form, :two_question_open_ended_form, organization:) } let(:admin) { FactoryBot.create(:user, :admin, organization: organization) } let!(:user_role) { FactoryBot.create(:user_role, user: admin, form: form, role: UserRole::Role::FormManager) } @@ -134,7 +134,6 @@ context 'with valid params' do before do - form.questions.create!(text: "Question one", question_type: :text_field, form_section: form.form_sections.first, answer_field: :answer_01, position: 1) form.created_at = Time.now - 2.weeks 20.times { FactoryBot.create(:submission, form: form) } form.save! @@ -208,4 +207,78 @@ expect(response).to redirect_to(admin_forms_url) end end + + describe 'GET #export' do + let(:start_date) { '2024-01-01' } + let(:end_date) { '2024-01-28' } + let!(:submission1) { FactoryBot.create(:submission, form:, created_at: '2024-01-01 08:00:00') } + let!(:submission2) { FactoryBot.create(:submission, form:, created_at: '2024-01-15 12:00:00') } + let!(:submission3) { FactoryBot.create(:submission, form:, created_at: '2024-01-28 23:59:59') } + let!(:out_of_range) { FactoryBot.create(:submission, form:, created_at: '2024-01-29 00:00:01') } + + context 'when response count is within the small download limit' do + it 'sends a CSV file' do + get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date } + expect(response.content_type).to include('text/csv') + expect(response.status).to eq(200) + expect(response.body).to include("ID,UUID,Test Text Field,Test Open Area,Location Code,User Agent,Status,Archived,Flagged,Page,Query string,Hostname,Referrer,Created At,IP Address,Tags") + expect(response.body).to include(submission1.created_at.to_s) + expect(response.body).to include(submission2.created_at.to_s) + expect(response.body).to include(submission3.created_at.to_s) + expect(response.body).to_not include(out_of_range.created_at.to_s) + end + end + + context 'when response count exceeds small download limit but is within async job range' do + before do + allow(Form).to receive(:find_by_short_uuid).with(form.short_uuid).and_return(form) + allow(form).to receive(:non_flagged_submissions).and_return(double(count: 1_500)) + allow(ExportJob).to receive(:perform_later) + end + + it 'queues an async export job and redirects' do + get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date } + + expect(ExportJob).to have_received(:perform_later).with(admin.email, form.short_uuid, "2024-01-01 00:00:00 UTC", "2024-01-28 23:59:59 UTC") + expect(response).to redirect_to(responses_admin_form_path(form)) + expect(flash[:success]).to eq(UserMailer::ASYNC_JOB_MESSAGE) + end + end + + context 'when response count exceeds the maximum allowed export' do + before do + allow(Form).to receive(:find_by_short_uuid).with(form.short_uuid).and_return(form) + allow(form).to receive(:non_flagged_submissions).and_return(double(count: described_class::MAX_ROWS_TO_EXPORT + 1)) + end + + it 'returns a bad request error' do + get :export, params: { id: form.short_uuid, start_date: start_date, end_date: end_date } + + expect(response).to have_http_status(:bad_request) + expect(response.body).to include("Your response set contains") + end + end + + context 'when no date parameters are provided' do + let(:default_start) { Time.zone.now.beginning_of_quarter } + let(:default_end) { Time.zone.now.end_of_quarter } + let!(:submission1) { FactoryBot.create(:submission, form:, created_at: default_start + 1.day) } + let!(:submission2) { FactoryBot.create(:submission, form:, created_at: default_start + 2.days) } + let!(:submission3) { FactoryBot.create(:submission, form:, created_at: default_start + 3.days) } + let!(:out_of_range1) { FactoryBot.create(:submission, form:, created_at: default_start - 1.day) } + let!(:out_of_range2) { FactoryBot.create(:submission, form:, created_at: default_end + 1.day) } + + it 'uses the default quarter range' do + get :export, params: { id: form.short_uuid } + + expect(response).to have_http_status(:ok) + expect(response.body).to include(submission1.created_at.to_s) + expect(response.body).to include(submission2.created_at.to_s) + expect(response.body).to include(submission3.created_at.to_s) + expect(response.body).to_not include(out_of_range1.created_at.to_s) + expect(response.body).to_not include(out_of_range2.created_at.to_s) + end + end + end + end diff --git a/spec/features/admin/offerings_spec.rb b/spec/features/admin/offerings_spec.rb deleted file mode 100644 index a938b7bba..000000000 --- a/spec/features/admin/offerings_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -feature 'Offerings', js: true do - let(:organization) { FactoryBot.create(:organization) } - let(:admin) { FactoryBot.create(:user, :admin, organization:) } - let(:user) { FactoryBot.create(:user, organization:) } - - context 'as Admin' do - before do - login_as admin - end - - describe '#index' do - before do - visit admin_offerings_path - end - - it 'load the Offerings#index page' do - expect(page).to have_content('Offerings') - expect(page).to have_link('New Offering') - expect(page.current_path).to eq(admin_offerings_path) - end - end - end -end diff --git a/spec/features/embedded_touchpoints_spec.rb b/spec/features/embedded_touchpoints_spec.rb index a08b49edd..c582f4e23 100644 --- a/spec/features/embedded_touchpoints_spec.rb +++ b/spec/features/embedded_touchpoints_spec.rb @@ -6,8 +6,9 @@ let(:organization) { FactoryBot.create(:organization) } let!(:user) { FactoryBot.create(:user, :admin, organization:) } + [true, false].each do |legacy_link_feature_flag_setting| context "as Admin" do - let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:) } + let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:, legacy_link_feature_flag: legacy_link_feature_flag_setting) } describe '/forms/:id/example' do before do @@ -96,4 +97,5 @@ end end end + end # legacy_link_feature_flag_setting end diff --git a/spec/fixtures/sample_cx_responses_upload_with_bom.csv b/spec/fixtures/sample_cx_responses_upload_with_bom.csv new file mode 100644 index 000000000..936128488 --- /dev/null +++ b/spec/fixtures/sample_cx_responses_upload_with_bom.csv @@ -0,0 +1,5 @@ +external_id,question_1,positive_effectiveness,positive_ease,positive_efficiency,positive_transparency,positive_humanity,positive_employee,positive_other,negative_effectiveness,negative_ease,negative_efficiency,negative_transparency,negative_humanity,negative_employee,negative_other,question_4 +100001,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,"alright" +100002,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,"" +100003,1,1,1,0,1,1,1,0,0,0,0,0,0,0,0,Excellent listening +100004,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,"Very intuitive experience, easy!" diff --git a/spec/models/form_spec.rb b/spec/models/form_spec.rb index 01e8b36bd..fb434493e 100644 --- a/spec/models/form_spec.rb +++ b/spec/models/form_spec.rb @@ -172,6 +172,24 @@ end end + describe '#non_flagged_submissions' do + let!(:submission1) { FactoryBot.create(:submission, form:, created_at: '2024-01-01 08:00:00') } + let!(:submission2) { FactoryBot.create(:submission, form:, created_at: '2024-01-15 12:00:00') } + let!(:submission3) { FactoryBot.create(:submission, form:, created_at: '2024-01-28 23:59:59') } + let!(:out_of_range) { FactoryBot.create(:submission, form:, created_at: '2024-01-29 00:00:01') } + + before do + start_date = Date.parse('2024-01-01').beginning_of_day + end_date = Date.parse('2024-01-28').end_of_day + @results = form.non_flagged_submissions(start_date:, end_date:) + end + + it 'includes submissions up to and including the end_date' do + expect(@results).to include(submission1, submission2, submission3) + expect(@results).not_to include(out_of_range) + end + end + describe '#to_a11_header_csv' do it 'returns Submission fields' do csv = form.to_a11_header_csv(start_date: Time.zone.now.to_date, end_date: 3.months.from_now.to_date)