diff --git a/app/controllers/api/public/v1/base_controller.rb b/app/controllers/api/public/v1/base_controller.rb new file mode 100644 index 000000000..3aabeef32 --- /dev/null +++ b/app/controllers/api/public/v1/base_controller.rb @@ -0,0 +1,29 @@ +class API::Public::V1::BaseController < APIController + skip_forgery_protection + + before_action :check_content_type_is_json + + protected + + def render_missing_param(param_name) + render_error("#{param_name} is missing", :bad_request) + end + + def render_bad_request(error_message) + render_error(error_message, :bad_request) + end + + def render_not_found(resource_name, resource_id) + render_error("#{resource_name} #{resource_id} is not found", :not_found) + end + + private + + def check_content_type_is_json + render_error("Content-Type should be json", :bad_request) unless request.headers['Content-Type'] == 'application/json' + end + + def render_error(message, status) + render json: { error: message }, status: status + end +end diff --git a/app/controllers/api/public/v1/dossiers_controller.rb b/app/controllers/api/public/v1/dossiers_controller.rb new file mode 100644 index 000000000..cffe7aa66 --- /dev/null +++ b/app/controllers/api/public/v1/dossiers_controller.rb @@ -0,0 +1,26 @@ +class API::Public::V1::DossiersController < API::Public::V1::BaseController + before_action :retrieve_procedure + + def create + dossier = Dossier.new( + revision: @procedure.active_revision, + groupe_instructeur: @procedure.defaut_groupe_instructeur_for_new_dossier, + state: Dossier.states.fetch(:brouillon), + prefilled: true + ) + dossier.build_default_individual + if dossier.save + dossier.prefill!(PrefillParams.new(dossier, params.to_unsafe_h).to_a) + render json: { dossier_url: commencer_url(@procedure.path, prefill_token: dossier.prefill_token) }, status: :created + else + render_bad_request(dossier.errors.full_messages.to_sentence) + end + end + + private + + def retrieve_procedure + @procedure = Procedure.publiees_ou_brouillons.find_by(id: params[:id]) + render_not_found("procedure", params[:id]) if @procedure.blank? + end +end diff --git a/app/controllers/concerns/procedure_context_concern.rb b/app/controllers/concerns/procedure_context_concern.rb index d2d7b5a2c..670df54c9 100644 --- a/app/controllers/concerns/procedure_context_concern.rb +++ b/app/controllers/concerns/procedure_context_concern.rb @@ -5,12 +5,14 @@ module ProcedureContextConcern include Devise::StoreLocationExtension def restore_procedure_context - if has_stored_procedure_path? - @procedure = find_procedure_in_context + return unless has_stored_procedure_path? - if @procedure.blank? - invalid_procedure_context - end + @procedure = find_procedure_in_context + + if @procedure.blank? + invalid_procedure_context + else + @prefill_token = find_prefill_token_in_context end end @@ -33,6 +35,11 @@ module ProcedureContextConcern end end + def find_prefill_token_in_context + uri = URI(get_stored_location_for(:user)) + CGI.parse(uri.query).dig("prefill_token")&.first if uri.query + end + def invalid_procedure_context clear_stored_location_for(:user) flash.alert = t('errors.messages.procedure_not_found') diff --git a/app/controllers/users/commencer_controller.rb b/app/controllers/users/commencer_controller.rb index c336738da..c416c98f8 100644 --- a/app/controllers/users/commencer_controller.rb +++ b/app/controllers/users/commencer_controller.rb @@ -4,6 +4,10 @@ module Users layout 'procedure_context' + before_action :retrieve_prefilled_dossier, if: -> { params[:prefill_token].present? }, only: :commencer + before_action :set_prefilled_dossier_ownership, if: -> { user_signed_in? && @prefilled_dossier&.orphan? }, only: :commencer + before_action :check_prefilled_dossier_ownership, if: -> { user_signed_in? && @prefilled_dossier }, only: :commencer + def commencer @procedure = retrieve_procedure return procedure_not_found if @procedure.blank? || @procedure.brouillon? @@ -74,6 +78,21 @@ module Users Procedure.publiees.or(Procedure.brouillons).or(Procedure.closes).find_by(path: params[:path]) end + def retrieve_prefilled_dossier + @prefilled_dossier = Dossier.state_brouillon.prefilled.find_by!(prefill_token: params[:prefill_token]) + end + + # The prefilled dossier is not owned yet, and the user is signed in: they become the new owner + def set_prefilled_dossier_ownership + @prefilled_dossier.update!(user: current_user) + DossierMailer.with(dossier: @prefilled_dossier).notify_new_draft.deliver_later + end + + # The prefilled dossier is owned by another user: raise an exception + def check_prefilled_dossier_ownership + raise ActiveRecord::RecordNotFound unless @prefilled_dossier.owned_by?(current_user) + end + def procedure_not_found procedure = Procedure.find_by(path: params[:path]) @@ -92,7 +111,7 @@ module Users end def store_user_location!(procedure) - store_location_for(:user, helpers.procedure_lien(procedure)) + store_location_for(:user, helpers.procedure_lien(procedure, prefill_token: params[:prefill_token])) end def generate_empty_pdf(revision) diff --git a/app/controllers/users/confirmations_controller.rb b/app/controllers/users/confirmations_controller.rb index ea3a1c37b..257456af0 100644 --- a/app/controllers/users/confirmations_controller.rb +++ b/app/controllers/users/confirmations_controller.rb @@ -45,7 +45,7 @@ class Users::ConfirmationsController < Devise::ConfirmationsController end if procedure_from_params - commencer_path(path: procedure_from_params.path) + commencer_path(path: procedure_from_params.path, prefill_token: params[:prefill_token]) elsif signed_in? # Will try to use `stored_location_for` to find a path after_sign_in_path_for(resource_name) diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 557050554..9136649cd 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -26,6 +26,7 @@ class Users::RegistrationsController < Devise::RegistrationsController # all Devise code. # So instead we use a per-request global variable. CurrentConfirmation.procedure_after_confirmation = @procedure + CurrentConfirmation.prefill_token = @prefill_token # Handle existing user trying to sign up again existing_user = User.find_by(email: params[:user][:email]) diff --git a/app/helpers/procedure_helper.rb b/app/helpers/procedure_helper.rb index 7152824ba..23af7da97 100644 --- a/app/helpers/procedure_helper.rb +++ b/app/helpers/procedure_helper.rb @@ -1,9 +1,9 @@ module ProcedureHelper - def procedure_lien(procedure) + def procedure_lien(procedure, prefill_token: nil) if procedure.brouillon? - commencer_test_url(path: procedure.path) + commencer_test_url(path: procedure.path, prefill_token: prefill_token) else - commencer_url(path: procedure.path) + commencer_url(path: procedure.path, prefill_token: prefill_token) end end diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index bc548c166..964fa2c5e 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -23,6 +23,7 @@ class DeviseUserMailer < Devise::Mailer def confirmation_instructions(record, token, opts = {}) opts[:from] = NO_REPLY_EMAIL @procedure = opts[:procedure_after_confirmation] || nil + @prefill_token = opts[:prefill_token] super end end diff --git a/app/models/concerns/dossier_prefillable_concern.rb b/app/models/concerns/dossier_prefillable_concern.rb index ce88bc01b..35cf970ed 100644 --- a/app/models/concerns/dossier_prefillable_concern.rb +++ b/app/models/concerns/dossier_prefillable_concern.rb @@ -3,10 +3,11 @@ module DossierPrefillableConcern extend ActiveSupport::Concern - def prefill!(champs_attributes) - return if champs_attributes.empty? + def prefill!(champs_public_attributes) + attr = { prefilled: true } + attr[:champs_public_attributes] = champs_public_attributes.map { |h| h.merge(prefilled: true) } if champs_public_attributes.any? - assign_attributes(champs_attributes: champs_attributes.map { |h| h.merge(prefilled: true) }) + assign_attributes(attr) save(validate: false) end end diff --git a/app/models/current_confirmation.rb b/app/models/current_confirmation.rb index c25314d7b..ce9853ee2 100644 --- a/app/models/current_confirmation.rb +++ b/app/models/current_confirmation.rb @@ -1,3 +1,4 @@ class CurrentConfirmation < ActiveSupport::CurrentAttributes attribute :procedure_after_confirmation + attribute :prefill_token end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d61596475..fa490d13e 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -28,6 +28,8 @@ # last_champ_updated_at :datetime # last_commentaire_updated_at :datetime # motivation :text +# prefill_token :string +# prefilled :boolean # private_search_terms :text # processed_at :datetime # search_terms :text @@ -70,6 +72,8 @@ class Dossier < ApplicationRecord DAYS_AFTER_EXPIRATION = 5 INTERVAL_EXPIRATION = "#{MONTHS_AFTER_EXPIRATION} month #{DAYS_AFTER_EXPIRATION} days" + has_secure_token :prefill_token + has_one :etablissement, dependent: :destroy has_one :individual, validate: false, dependent: :destroy has_one :attestation, dependent: :destroy @@ -218,11 +222,12 @@ class Dossier < ApplicationRecord scope :state_termine, -> { where(state: TERMINE) } scope :state_not_termine, -> { where.not(state: TERMINE) } - scope :archived, -> { where(archived: true) } - scope :not_archived, -> { where(archived: false) } - scope :hidden_by_user, -> { where.not(hidden_by_user_at: nil) } - scope :hidden_by_administration, -> { where.not(hidden_by_administration_at: nil) } - scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil) } + scope :archived, -> { where(archived: true) } + scope :not_archived, -> { where(archived: false) } + scope :prefilled, -> { where(prefilled: true) } + scope :hidden_by_user, -> { where.not(hidden_by_user_at: nil) } + scope :hidden_by_administration, -> { where.not(hidden_by_administration_at: nil) } + scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil) } scope :visible_by_administration, -> { state_not_brouillon .where(hidden_by_administration_at: nil) @@ -435,7 +440,7 @@ class Dossier < ApplicationRecord after_save :send_web_hook - validates :user, presence: true, if: -> { deleted_user_email_never_send.nil? } + validates :user, presence: true, if: -> { deleted_user_email_never_send.nil? }, unless: -> { prefilled } validates :individual, presence: true, if: -> { revision.procedure.for_individual? } validates :groupe_instructeur, presence: true, if: -> { !brouillon? } @@ -718,6 +723,17 @@ class Dossier < ApplicationRecord end end + def orphan? + prefilled? && user.nil? + end + + def owned_by?(a_user) + return false if a_user.nil? + return false if orphan? + + user == a_user + end + def log_operations? !procedure.brouillon? && !brouillon? end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 34ea5f432..e2d783a92 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -206,7 +206,7 @@ class Procedure < ApplicationRecord scope :brouillons, -> { where(aasm_state: :brouillon) } scope :publiees, -> { where(aasm_state: :publiee) } - scope :publiees_ou_brouillons, -> { publiees.or(brouillons) } + scope :publiees_ou_brouillons, -> { where(aasm_state: [:publiee, :brouillon]) } scope :closes, -> { where(aasm_state: [:close, :depubliee]) } scope :opendata, -> { where(opendata: true) } scope :publiees_ou_closes, -> { where(aasm_state: [:publiee, :close, :depubliee]) } diff --git a/app/models/user.rb b/app/models/user.rb index 5d6589829..0163890b7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -80,6 +80,7 @@ class User < ApplicationRecord # Make our procedure_after_confirmation available to the Mailer opts[:procedure_after_confirmation] = CurrentConfirmation.procedure_after_confirmation + opts[:prefill_token] = CurrentConfirmation.prefill_token send_devise_notification(:confirmation_instructions, @raw_confirmation_token, opts) end diff --git a/app/views/commencer/show.html.haml b/app/views/commencer/show.html.haml index 0b755b554..553f1198e 100644 --- a/app/views/commencer/show.html.haml +++ b/app/views/commencer/show.html.haml @@ -3,12 +3,12 @@ .commencer.form - if !user_signed_in? %h2.huge-title= t('views.commencer.show.start_procedure') - = render partial: 'shared/france_connect_login', locals: { url: commencer_france_connect_path(path: @procedure.path) } - = link_to commencer_sign_up_path(path: @procedure.path), class: 'fr-btn fr-btn--lg fr-my-2w' do + = render partial: 'shared/france_connect_login', locals: { url: commencer_france_connect_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token) } + = link_to commencer_sign_up_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--lg fr-my-2w' do = t('views.shared.account.create') %span.optional-on-small-screens.fr-ml-1v #{APPLICATION_NAME} - = link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path), class: 'fr-btn fr-btn--secondary fr-btn--lg fr-my-2w' + = link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--secondary fr-btn--lg fr-my-2w' - else - revision = @revision.draft? ? @revision : @procedure.revisions.where.not(id: @procedure.draft_revision_id) @@ -19,6 +19,13 @@ - if dossiers.empty? = link_to t('views.commencer.show.start_procedure'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-my-2w' + - elsif @prefilled_dossier + %h2.huge-title= t('views.commencer.show.prefilled_draft') + %p + = t('views.commencer.show.prefilled_draft_detail_html', time_ago: time_ago_in_words(@prefilled_dossier.created_at), procedure: @prefilled_dossier.procedure.libelle) + = link_to t('views.commencer.show.continue_file'), brouillon_dossier_path(@prefilled_dossier), class: 'fr-btn fr-btn--lg fr-my-2w' + = link_to t('views.commencer.show.start_new_file'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-btn--secondary fr-my-2w' + - elsif drafts.size == 1 && not_drafts.empty? - dossier = drafts.first %h2.huge-title= t('views.commencer.show.already_draft') diff --git a/app/views/devise_mailer/confirmation_instructions.html.haml b/app/views/devise_mailer/confirmation_instructions.html.haml index cab00c327..ba4904207 100644 --- a/app/views/devise_mailer/confirmation_instructions.html.haml +++ b/app/views/devise_mailer/confirmation_instructions.html.haml @@ -6,7 +6,7 @@ %p Pour activer votre compte sur #{APPLICATION_NAME}, veuillez cliquer sur le lien suivant : - - link = confirmation_url(@user, confirmation_token: @token, procedure_id: @procedure&.id) + - link = confirmation_url(@user, confirmation_token: @token, procedure_id: @procedure&.id, prefill_token: @prefill_token) = link_to(link, link) - else diff --git a/config/locales/en.yml b/config/locales/en.yml index 20be22169..8f5a48112 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -92,6 +92,8 @@ en: start_procedure: Start the procedure existing_dossiers: You already have files for this procedure show_dossiers: View my current files + prefilled_draft: "You have a prefilled file" + prefilled_draft_detail_html: "You prefilled a file for the \"%{procedure}\" procedure %{time_ago} ago" already_draft: "You already started to fill a file" already_draft_detail_html: "You started to fill a file for the \"%{procedure}\" procedure %{time_ago} ago" already_not_draft: "You already submitted a file" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 8367c0ca5..f73bd0001 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -82,6 +82,8 @@ fr: start_procedure: Commencer la démarche existing_dossiers: Vous avez déjà des dossiers pour cette démarche show_dossiers: Voir mes dossiers en cours + prefilled_draft: "Vous avez un dossier prérempli" + prefilled_draft_detail_html: "Il y a %{time_ago}, vous avez prérempli un dossier sur la démarche « %{procedure} »." already_draft: "Vous avez déjà commencé à remplir un dossier" already_draft_detail_html: "Il y a %{time_ago}, vous avez commencé à remplir un dossier sur la démarche « %{procedure} »." already_not_draft: "Vous avez déjà déposé un dossier" diff --git a/config/routes.rb b/config/routes.rb index d7a66e23f..4d165b3e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -263,7 +263,11 @@ Rails.application.routes.draw do namespace :public do namespace :v1 do - resources :dossiers, only: :create + resources :demarches, only: [] do + member do + resources :dossiers, only: :create + end + end end end end diff --git a/db/migrate/20221213084333_add_prefill_fields_to_dossiers.rb b/db/migrate/20221213084333_add_prefill_fields_to_dossiers.rb new file mode 100644 index 000000000..28c34a296 --- /dev/null +++ b/db/migrate/20221213084333_add_prefill_fields_to_dossiers.rb @@ -0,0 +1,6 @@ +class AddPrefillFieldsToDossiers < ActiveRecord::Migration[6.1] + def change + add_column :dossiers, :prefill_token, :string + add_column :dossiers, :prefilled, :boolean + end +end diff --git a/db/migrate/20221213084442_add_prefill_token_index_to_dossiers.rb b/db/migrate/20221213084442_add_prefill_token_index_to_dossiers.rb new file mode 100644 index 000000000..349224504 --- /dev/null +++ b/db/migrate/20221213084442_add_prefill_token_index_to_dossiers.rb @@ -0,0 +1,7 @@ +class AddPrefillTokenIndexToDossiers < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :dossiers, :prefill_token, unique: true, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 309bb650c..313080e07 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.define(version: 2022_12_05_144624) do +ActiveRecord::Schema.define(version: 2022_12_13_084442) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -298,13 +298,13 @@ ActiveRecord::Schema.define(version: 2022_12_05_144624) do t.boolean "automatic_operation", default: false, null: false t.bigint "bill_signature_id" t.datetime "created_at", null: false + t.jsonb "data" t.text "digest" t.bigint "dossier_id" t.datetime "executed_at" t.datetime "keep_until" t.string "operation", null: false t.datetime "updated_at", null: false - t.jsonb "data" t.index ["bill_signature_id"], name: "index_dossier_operation_logs_on_bill_signature_id" t.index ["dossier_id"], name: "index_dossier_operation_logs_on_dossier_id" t.index ["keep_until"], name: "index_dossier_operation_logs_on_keep_until" @@ -363,6 +363,8 @@ ActiveRecord::Schema.define(version: 2022_12_05_144624) do t.datetime "last_commentaire_updated_at" t.text "motivation" t.bigint "parent_dossier_id" + t.string "prefill_token" + t.boolean "prefilled" t.string "private_search_terms" t.datetime "processed_at" t.bigint "revision_id" @@ -376,6 +378,7 @@ ActiveRecord::Schema.define(version: 2022_12_05_144624) do t.index ["dossier_transfer_id"], name: "index_dossiers_on_dossier_transfer_id" t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id" t.index ["hidden_at"], name: "index_dossiers_on_hidden_at" + t.index ["prefill_token"], name: "index_dossiers_on_prefill_token", unique: true t.index ["revision_id"], name: "index_dossiers_on_revision_id" t.index ["state"], name: "index_dossiers_on_state" t.index ["user_id"], name: "index_dossiers_on_user_id" @@ -807,6 +810,7 @@ ActiveRecord::Schema.define(version: 2022_12_05_144624) do t.datetime "reset_password_sent_at" t.string "reset_password_token" t.integer "sign_in_count", default: 0, null: false + t.boolean "team_account", default: false t.string "unlock_token" t.datetime "updated_at" t.index ["email"], name: "index_super_admins_on_email", unique: true @@ -885,7 +889,6 @@ ActiveRecord::Schema.define(version: 2022_12_05_144624) do t.string "reset_password_token" t.integer "sign_in_count", default: 0, null: false t.string "siret" - t.boolean "team_account", default: false t.text "unconfirmed_email" t.string "unlock_token" t.datetime "updated_at" diff --git a/spec/controllers/api/public/v1/dossiers_controller_spec.rb b/spec/controllers/api/public/v1/dossiers_controller_spec.rb new file mode 100644 index 000000000..e4cc80de9 --- /dev/null +++ b/spec/controllers/api/public/v1/dossiers_controller_spec.rb @@ -0,0 +1,135 @@ +RSpec.describe API::Public::V1::DossiersController, type: :controller do + include Rails.application.routes.url_helpers + + describe '#create' do + # Request prototype: + # curl --request POST 'http://localhost:3000/api/public/v1/demarches/2/dossiers' \ + # --header 'Content-Type: application/json' \ + # --data '{"champ_Q2hhbXAtMjI=": "personne@fournisseur.fr"}' + + context 'when the request content type is json' do + let(:params) { { id: procedure.id } } + subject(:create_request) do + request.headers["Content-Type"] = "application/json" + post :create, params: params + end + + shared_examples 'the procedure is found' do + context 'when the dossier can be saved' do + it { expect(create_request).to have_http_status(:created) } + + it { expect { create_request }.to change { Dossier.count }.by(1) } + + it "marks the created dossier as prefilled" do + create_request + expect(Dossier.last.prefilled).to eq(true) + end + + it "creates the dossier without a user" do + create_request + expect(Dossier.last.user).to eq(nil) + end + + it "responds with the brouillon dossier path" do + create_request + expect(JSON.parse(response.body)["dossier_url"]).to eq( + "http://test.host#{commencer_path(procedure.path, prefill_token: Dossier.last.prefill_token)}" + ) + end + + context 'when prefill values are given' do + let!(:type_de_champ_1) { create(:type_de_champ_text, procedure: procedure) } + let(:value_1) { "any value" } + + let!(:type_de_champ_2) { create(:type_de_champ_textarea, procedure: procedure) } + let(:value_2) { "another value" } + + let(:params) { + { + id: procedure.id, + "champ_#{type_de_champ_1.to_typed_id}" => value_1, + "champ_#{type_de_champ_2.to_typed_id}" => value_2 + } + } + + it "prefills the dossier's champs with the given values" do + create_request + + dossier = Dossier.last + expect(find_champ_by_stable_id(dossier, type_de_champ_1.stable_id).value).to eq(value_1) + expect(find_champ_by_stable_id(dossier, type_de_champ_2.stable_id).value).to eq(value_2) + end + end + end + + context 'when the dossier can not be saved' do + before do + allow_any_instance_of(Dossier).to receive(:save).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( + ActiveModel::Errors.new(Dossier.new).tap { |e| e.add(:base, "something went wrong") } + ) + + create_request + end + + it { expect(response).to have_http_status(:bad_request) } + + it { expect(response).to have_failed_with("something went wrong") } + end + end + + shared_examples 'the procedure is not found' do + before { create_request } + + it { expect(response).to have_http_status(:not_found) } + + it { expect(response).to have_failed_with("procedure #{procedure.id} is not found") } + end + + context 'when the procedure is found' do + context 'when the procedure is publiee' do + it_behaves_like 'the procedure is found' do + let(:procedure) { create(:procedure, :published) } + end + end + + context 'when the procedure is brouillon' do + it_behaves_like 'the procedure is found' do + let(:procedure) { create(:procedure, :draft) } + end + end + + context 'when the procedure is not publiee and not brouillon' do + it_behaves_like 'the procedure is not found' do + let(:procedure) { create(:procedure, :closed) } + end + end + end + + context 'when the procedure is not found' do + it_behaves_like 'the procedure is not found' do + let(:procedure) { double(Procedure, id: -1) } + end + end + end + + context 'when the request content type is not json' do + subject(:create_request) do + request.headers["Content-Type"] = "application/xhtml+xml" + post :create, params: { id: 0 } + end + + before { create_request } + + it { expect(response).to have_http_status(:bad_request) } + + it { expect(response).to have_failed_with("Content-Type should be json") } + end + end + + private + + def find_champ_by_stable_id(dossier, stable_id) + dossier.champs_public.joins(:type_de_champ).find_by(types_de_champ: { stable_id: stable_id }) + end +end diff --git a/spec/controllers/concerns/procedure_context_concern_spec.rb b/spec/controllers/concerns/procedure_context_concern_spec.rb index 9a3f81e25..10f6a185d 100644 --- a/spec/controllers/concerns/procedure_context_concern_spec.rb +++ b/spec/controllers/concerns/procedure_context_concern_spec.rb @@ -69,6 +69,19 @@ RSpec.describe ProcedureContextConcern, type: :controller do expect(subject.status).to eq 200 expect(assigns(:procedure)).to eq test_procedure end + + context 'when a prefill token has been stored' do + let(:dossier) { create :dossier, :prefilled, procedure: test_procedure } + + before do + controller.store_location_for(:user, commencer_test_path(path: test_procedure.path, prefill_token: dossier.prefill_token)) + end + + it 'succeeds, and assigns the prefill token on the controller' do + expect(subject.status).to eq 200 + expect(assigns(:prefill_token)).to eq dossier.prefill_token + end + end end context 'when the stored procedure is published' do @@ -82,6 +95,19 @@ RSpec.describe ProcedureContextConcern, type: :controller do expect(subject.status).to eq 200 expect(assigns(:procedure)).to eq published_procedure end + + context 'when a prefill token has been stored' do + let(:dossier) { create :dossier, :prefilled, procedure: published_procedure } + + before do + controller.store_location_for(:user, commencer_path(path: published_procedure.path, prefill_token: dossier.prefill_token)) + end + + it 'succeeds, and assigns the prefill token on the controller' do + expect(subject.status).to eq 200 + expect(assigns(:prefill_token)).to eq dossier.prefill_token + end + end end end end diff --git a/spec/controllers/users/commencer_controller_spec.rb b/spec/controllers/users/commencer_controller_spec.rb index d64094521..f4370c526 100644 --- a/spec/controllers/users/commencer_controller_spec.rb +++ b/spec/controllers/users/commencer_controller_spec.rb @@ -72,6 +72,85 @@ describe Users::CommencerController, type: :controller do expect(subject).to redirect_to(commencer_path(path: replaced_by_procedure.path)) end end + + context 'when a dossier has been prefilled' do + let(:dossier) { create(:dossier, :brouillon, :prefilled, user: user) } + let(:path) { dossier.procedure.path } + + subject { get :commencer, params: { path: path, prefill_token: dossier.prefill_token } } + + shared_examples 'a prefilled brouillon dossier retriever' do + context 'when the dossier is a prefilled brouillon and the prefill token is present' do + it 'retrieves the dossier' do + subject + expect(assigns(:prefilled_dossier)).to eq(dossier) + end + end + + context 'when the dossier is not prefilled' do + before do + dossier.prefilled = false + dossier.save(validate: false) + end + + it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + end + + context 'when the dossier is not a brouillon' do + before { dossier.en_construction! } + + it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + end + + context 'when the prefill token does not match any dossier' do + before { dossier.prefill_token = "totoro" } + + it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + end + end + + context 'when the user is unauthenticated' do + let(:user) { nil } + + it_behaves_like 'a prefilled brouillon dossier retriever' + end + + context 'when the user is authenticated' do + context 'when the dossier already has an owner' do + let(:user) { create(:user) } + + context 'when the user is the dossier owner' do + before { sign_in user } + + it_behaves_like 'a prefilled brouillon dossier retriever' + end + + context 'when the user is not the dossier owner' do + before { sign_in create(:user) } + + it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + end + end + + context 'when the dossier does not have an owner yet' do + let(:user) { nil } + let(:newly_authenticated_user) { create(:user) } + + before { sign_in newly_authenticated_user } + + it { expect { subject }.to change { dossier.reload.user }.from(nil).to(newly_authenticated_user) } + + it 'sends the notify_new_draft email' do + expect { perform_enqueued_jobs { subject } }.to change { ActionMailer::Base.deliveries.count }.by(1) + + dossier = Dossier.last + mail = ActionMailer::Base.deliveries.last + expect(mail.subject).to eq("Retrouvez votre brouillon pour la démarche « #{dossier.procedure.libelle} »") + expect(mail.html_part.body).to include(dossier_path(dossier)) + end + end + end + end end describe '#commencer_test' do @@ -105,6 +184,13 @@ describe Users::CommencerController, type: :controller do end end + shared_examples 'a prefill token storage' do + it 'stores the prefill token' do + subject + expect(controller.stored_location_for(:user)).to include('prefill_token') + end + end + describe '#sign_in' do context 'for a published procedure' do subject { get :sign_in, params: { path: published_procedure.path } } @@ -115,6 +201,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(new_user_session_path) } + + context 'when a prefill token is given' do + subject { get :sign_in, params: { path: published_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'for a draft procedure' do @@ -126,6 +218,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(new_user_session_path) } + + context 'when a prefill token is given' do + subject { get :sign_in, params: { path: draft_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'when the path doesn’t exist' do @@ -147,6 +245,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(new_user_registration_path) } + + context 'when a prefill token is given' do + subject { get :sign_up, params: { path: published_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'for a draft procedure' do @@ -158,6 +262,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(new_user_registration_path) } + + context 'when a prefill token is given' do + subject { get :sign_up, params: { path: draft_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'when the path doesn’t exist' do @@ -179,6 +289,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(france_connect_particulier_path) } + + context 'when a prefill token is given' do + subject { get :france_connect, params: { path: published_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'for a draft procedure' do @@ -190,6 +306,12 @@ describe Users::CommencerController, type: :controller do end it { expect(subject).to redirect_to(france_connect_particulier_path) } + + context 'when a prefill token is given' do + subject { get :france_connect, params: { path: draft_procedure.path, prefill_token: 'prefill_token' } } + + it_behaves_like 'a prefill token storage' + end end context 'when the path doesn’t exist' do diff --git a/spec/factories/dossier.rb b/spec/factories/dossier.rb index 56a4d36d9..c766505cd 100644 --- a/spec/factories/dossier.rb +++ b/spec/factories/dossier.rb @@ -256,5 +256,9 @@ FactoryBot.define do dossier.save! end end + + trait :prefilled do + prefilled { true } + end end end diff --git a/spec/mailers/previews/devise_user_mailer_preview.rb b/spec/mailers/previews/devise_user_mailer_preview.rb index e79fc17fc..1f0869098 100644 --- a/spec/mailers/previews/devise_user_mailer_preview.rb +++ b/spec/mailers/previews/devise_user_mailer_preview.rb @@ -8,6 +8,10 @@ class DeviseUserMailerPreview < ActionMailer::Preview DeviseUserMailer.confirmation_instructions(user, "faketoken", {}) end + def confirmation_instructions___with_procedure_and_prefill_token + DeviseUserMailer.confirmation_instructions(user, "faketoken", procedure_after_confirmation: procedure, prefill_token: "prefill_token") + end + def reset_password_instructions DeviseUserMailer.reset_password_instructions(user, "faketoken", {}) end diff --git a/spec/models/concern/dossier_prefillable_concern_spec.rb b/spec/models/concern/dossier_prefillable_concern_spec.rb index e6c8e6bfa..58de07d7d 100644 --- a/spec/models/concern/dossier_prefillable_concern_spec.rb +++ b/spec/models/concern/dossier_prefillable_concern_spec.rb @@ -8,12 +8,19 @@ RSpec.describe DossierPrefillableConcern do subject(:fill) { dossier.prefill!(values); dossier.reload } + shared_examples 'a dossier marked as prefilled' do + it 'marks the dossier as prefilled' do + expect { fill }.to change { dossier.reload.prefilled }.from(nil).to(true) + end + end + context 'when champs_public_attributes is empty' do let(:values) { [] } - it "does nothing" do - expect(dossier).not_to receive(:save) - fill + it_behaves_like 'a dossier marked as prefilled' + + it "doesn't change champs_public" do + expect { fill }.not_to change { dossier.champs_public.to_a } end end @@ -30,6 +37,8 @@ RSpec.describe DossierPrefillableConcern do let(:values) { [{ id: champ_id_1, value: value_1 }, { id: champ_id_2, value: value_2 }] } + it_behaves_like 'a dossier marked as prefilled' + it "updates the champs with the new values and mark them as prefilled" do fill @@ -48,6 +57,8 @@ RSpec.describe DossierPrefillableConcern do let(:values) { [{ id: champ_id, value: value }] } + it_behaves_like 'a dossier marked as prefilled' + it "still updates the champ" do expect { fill }.to change { dossier.champs_public.first.value }.from(nil).to(value) end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 165870ec8..9b1f509df 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -25,6 +25,20 @@ describe Dossier do subject(:dossier) { create(:dossier, procedure: procedure) } it { is_expected.to validate_presence_of(:individual) } + + it { is_expected.to validate_presence_of(:user) } + + context 'when dossier has deleted_user_email_never_send' do + subject(:dossier) { create(:dossier, procedure: procedure, deleted_user_email_never_send: "seb@totoro.org") } + + it { is_expected.not_to validate_presence_of(:user) } + end + + context 'when dossier is prefilled' do + subject(:dossier) { create(:dossier, procedure: procedure, prefilled: true) } + + it { is_expected.not_to validate_presence_of(:user) } + end end describe 'with_champs' do @@ -1928,6 +1942,70 @@ describe Dossier do it { is_expected.to belong_to(:batch_operation).optional } end + describe '#orphan?' do + subject(:orphan) { dossier.orphan? } + + context 'when the dossier is prefilled' do + context 'when the dossier has a user' do + let(:dossier) { build(:dossier, :prefilled) } + + it { expect(orphan).to be_falsey } + end + + context 'when the dossier does not have a user' do + let(:dossier) { build(:dossier, :prefilled, user: nil) } + + it { expect(orphan).to be_truthy } + end + end + + context 'when the dossier is not prefilled' do + context 'when the dossier has a user' do + let(:dossier) { build(:dossier) } + + it { expect(orphan).to be_falsey } + end + + context 'when the dossier does not have a user' do + let(:dossier) { build(:dossier, user: nil) } + + it { expect(orphan).to be_falsey } + end + end + end + + describe '#owned_by?' do + subject(:owned_by) { dossier.owned_by?(user) } + + context 'when the dossier is orphan' do + let(:dossier) { build(:dossier, user: nil) } + let(:user) { build(:user) } + + it { expect(owned_by).to be_falsey } + end + + context 'when the given user is nil' do + let(:dossier) { build(:dossier) } + let(:user) { nil } + + it { expect(owned_by).to be_falsey } + end + + context 'when the dossier has a user and it is not the given user' do + let(:dossier) { build(:dossier) } + let(:user) { build(:user) } + + it { expect(owned_by).to be_falsey } + end + + context 'when the dossier has a user and it is the given user' do + let(:dossier) { build(:dossier, user: user) } + let(:user) { build(:user) } + + it { expect(owned_by).to be_truthy } + end + end + private def count_for_month(processed_by_month, month) diff --git a/spec/support/public_api_matchers.rb b/spec/support/public_api_matchers.rb new file mode 100644 index 000000000..bf856520c --- /dev/null +++ b/spec/support/public_api_matchers.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :have_failed_with do |expected| + match do |response| + JSON.parse(response.body).with_indifferent_access.dig(:error) == expected + end +end diff --git a/spec/support/shared_examples_for_prefilled_dossier.rb b/spec/support/shared_examples_for_prefilled_dossier.rb new file mode 100644 index 000000000..f5a8faaf8 --- /dev/null +++ b/spec/support/shared_examples_for_prefilled_dossier.rb @@ -0,0 +1,22 @@ +shared_examples "the user has got a prefilled dossier, owned by themselves" do + scenario "the user has got a prefilled dossier, owned by themselves" do + siret = '41816609600051' + stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v2\/etablissements\/#{siret}/) + .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/etablissements.json')) + + expect(dossier.user).to eq(user) + + expect(page).to have_current_path siret_dossier_path(procedure.dossiers.last) + fill_in 'Numéro SIRET', with: siret + click_on 'Valider' + + expect(page).to have_current_path(etablissement_dossier_path(dossier)) + expect(page).to have_content('OCTO TECHNOLOGY') + click_on 'Continuer avec ces informations' + + expect(page).to have_current_path(brouillon_dossier_path(dossier)) + expect(page).to have_field(type_de_champ_text.libelle, with: text_value) + expect(page).to have_field(type_de_champ_phone.libelle, with: phone_value) + expect(page).to have_css('.field_with_errors', text: type_de_champ_phone.libelle) + end +end diff --git a/spec/system/users/dossier_prefill_get_spec.rb b/spec/system/users/dossier_prefill_get_spec.rb new file mode 100644 index 000000000..d7b9eed35 --- /dev/null +++ b/spec/system/users/dossier_prefill_get_spec.rb @@ -0,0 +1,87 @@ +describe 'Prefilling a dossier (with a GET request):' do + let(:password) { 'my-s3cure-p4ssword' } + + let(:procedure) { create(:procedure, :published, opendata: true) } + let(:dossier) { procedure.dossiers.last } + + let(:type_de_champ_text) { create(:type_de_champ_text, procedure: procedure) } + let(:type_de_champ_phone) { create(:type_de_champ_phone, procedure: procedure) } + let(:text_value) { "My Neighbor Totoro is the best movie ever" } + let(:phone_value) { "invalid phone value" } + + context 'when authenticated' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user) { create(:user, password: password) } + + before do + visit "/users/sign_in" + sign_in_with user.email, password + + visit commencer_path( + path: procedure.path, + "champ_#{type_de_champ_text.to_typed_id}" => text_value, + "champ_#{type_de_champ_phone.to_typed_id}" => phone_value + ) + + click_on "Commencer la démarche" + end + end + end + + context 'when unauthenticated' do + before do + visit commencer_path( + path: procedure.path, + "champ_#{type_de_champ_text.to_typed_id}" => text_value, + "champ_#{type_de_champ_phone.to_typed_id}" => phone_value + ) + end + + context 'when the user signs in with email and password' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let!(:user) { create(:user, password: password) } + + before do + click_on "J’ai déjà un compte" + sign_in_with user.email, password + + click_on "Commencer la démarche" + end + end + end + + context 'when the user signs up with email and password' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user_email) { generate :user_email } + let(:user) { User.find_by(email: user_email) } + + before do + click_on "Créer un compte #{APPLICATION_NAME}" + + sign_up_with user_email, password + expect(page).to have_content "nous avons besoin de vérifier votre adresse #{user_email}" + + click_confirmation_link_for user_email + expect(page).to have_content('Votre compte a bien été confirmé.') + + click_on "Commencer la démarche" + end + end + end + + context 'when the user signs up with FranceConnect' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user) { User.last } + + before do + allow_any_instance_of(FranceConnectParticulierClient).to receive(:authorization_uri).and_return(france_connect_particulier_callback_path(code: "c0d3")) + allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(build(:france_connect_information)) + + page.find('.fr-connect').click + + click_on "Commencer la démarche" + end + end + end + end +end diff --git a/spec/system/users/dossier_prefill_post_spec.rb b/spec/system/users/dossier_prefill_post_spec.rb new file mode 100644 index 000000000..8b495da8d --- /dev/null +++ b/spec/system/users/dossier_prefill_post_spec.rb @@ -0,0 +1,102 @@ +describe 'Prefilling a dossier (with a POST request):' do + let(:password) { 'my-s3cure-p4ssword' } + + let(:procedure) { create(:procedure, :published) } + let(:dossier) { procedure.dossiers.last } + + let(:type_de_champ_text) { create(:type_de_champ_text, procedure: procedure) } + let(:type_de_champ_phone) { create(:type_de_champ_phone, procedure: procedure) } + let(:text_value) { "My Neighbor Totoro is the best movie ever" } + let(:phone_value) { "invalid phone value" } + + scenario "the user get the URL of a prefilled orphan brouillon dossier" do + dossier_url = create_and_prefill_dossier_with_post_request + + expect(dossier_url).to eq(commencer_path(procedure.path, prefill_token: dossier.prefill_token)) + end + + describe 'visit the dossier URL' do + context 'when authenticated' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user) { create(:user, password: password) } + + before do + visit "/users/sign_in" + sign_in_with user.email, password + + visit create_and_prefill_dossier_with_post_request + + expect(page).to have_content('Vous avez un dossier prérempli') + click_on 'Continuer à remplir mon dossier' + end + end + end + + context 'when unauthenticated' do + before { visit create_and_prefill_dossier_with_post_request } + + context 'when the user signs in with email and password' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user) { create(:user, password: password) } + + before do + click_on "J’ai déjà un compte" + sign_in_with user.email, password + + expect(page).to have_content('Vous avez un dossier prérempli') + click_on 'Continuer à remplir mon dossier' + end + end + end + + context 'when the user signs up with email and password' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user_email) { generate :user_email } + let(:user) { User.find_by(email: user_email) } + + before do + click_on "Créer un compte #{APPLICATION_NAME}" + + sign_up_with user_email, password + expect(page).to have_content "nous avons besoin de vérifier votre adresse #{user_email}" + + click_confirmation_link_for user_email + expect(page).to have_content('Votre compte a bien été confirmé.') + + expect(page).to have_content('Vous avez un dossier prérempli') + click_on 'Continuer à remplir mon dossier' + end + end + end + + context 'when the user signs up with FranceConnect' do + it_behaves_like "the user has got a prefilled dossier, owned by themselves" do + let(:user) { User.last } + + before do + allow_any_instance_of(FranceConnectParticulierClient).to receive(:authorization_uri).and_return(france_connect_particulier_callback_path(code: "c0d3")) + allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(build(:france_connect_information)) + + page.find('.fr-connect').click + + expect(page).to have_content('Vous avez un dossier prérempli') + click_on 'Continuer à remplir mon dossier' + end + end + end + end + end + + private + + def create_and_prefill_dossier_with_post_request + session = ActionDispatch::Integration::Session.new(Rails.application) + session.post api_public_v1_dossiers_path(procedure), + headers: { "Content-Type" => "application/json" }, + params: { + "champ_#{type_de_champ_text.to_typed_id}" => text_value, + "champ_#{type_de_champ_phone.to_typed_id}" => phone_value + }.to_json + JSON.parse(session.response.body)["dossier_url"].gsub("http://www.example.com", "") + end +end diff --git a/spec/system/users/dossier_prefill_spec.rb b/spec/system/users/dossier_prefill_spec.rb deleted file mode 100644 index 386feeba5..000000000 --- a/spec/system/users/dossier_prefill_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -describe 'Prefilling a dossier:' do - let(:password) { 'my-s3cure-p4ssword' } - let(:siret) { '41816609600051' } - - let(:procedure) { create(:procedure, :published, opendata: true) } - let(:dossier) { procedure.dossiers.last } - - let(:type_de_champ_text) { create(:type_de_champ_text, procedure: procedure) } - let(:type_de_champ_phone) { create(:type_de_champ_phone, procedure: procedure) } - let(:text_value) { "My Neighbor Totoro is the best movie ever" } - let(:phone_value) { "invalid phone value" } - - before do - stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v2\/etablissements\/#{siret}/) - .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/etablissements.json')) - - visit commencer_path( - path: procedure.path, - "champ_#{type_de_champ_text.to_typed_id}" => text_value, - "champ_#{type_de_champ_phone.to_typed_id}" => phone_value - ) - end - - shared_examples "the user has got a prefilled dossier" do - scenario "the user has got a prefilled dossier" do - click_on "Commencer la démarche" - - expect(page).to have_current_path siret_dossier_path(procedure.dossiers.last) - fill_in 'Numéro SIRET', with: siret - click_on 'Valider' - - expect(page).to have_current_path(etablissement_dossier_path(dossier)) - expect(page).to have_content('OCTO TECHNOLOGY') - click_on 'Continuer avec ces informations' - - expect(page).to have_current_path(brouillon_dossier_path(dossier)) - expect(page).to have_field(type_de_champ_text.libelle, with: text_value) - expect(page).to have_field(type_de_champ_phone.libelle, with: phone_value) - expect(page).to have_css('.field_with_errors', text: type_de_champ_phone.libelle) - end - end - - context 'when the user signs in with email and password' do - it_behaves_like "the user has got a prefilled dossier" do - let!(:user) { create(:user, password: password) } - - before do - click_on "J’ai déjà un compte" - sign_in_with user.email, password - end - end - end - - context 'when the user signs up with email and password' do - it_behaves_like "the user has got a prefilled dossier" do - let(:user_email) { generate :user_email } - - before do - click_on "Créer un compte #{APPLICATION_NAME}" - - sign_up_with user_email, password - expect(page).to have_content "nous avons besoin de vérifier votre adresse #{user_email}" - - click_confirmation_link_for user_email - expect(page).to have_content('Votre compte a bien été confirmé.') - end - end - end - - context 'when the user signs up with FranceConnect' do - it_behaves_like "the user has got a prefilled dossier" do - before do - allow_any_instance_of(FranceConnectParticulierClient).to receive(:authorization_uri).and_return(france_connect_particulier_callback_path(code: "c0d3")) - allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(build(:france_connect_information)) - - page.find('.fr-connect').click - end - end - end -end