Merge pull request #9101 from tchak/feat-accrediatation-jo

ETQ administrateur, je voudrais pouvoir utiliser le numéro d'accréditation Paris 2024
This commit is contained in:
Paul Chavard 2023-07-10 13:18:49 +00:00 committed by GitHub
commit 252eafd541
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 588 additions and 21 deletions

View file

@ -30,6 +30,7 @@ gem 'devise-i18n'
gem 'devise-two-factor'
gem 'discard'
gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded before rails
gem 'dry-monads'
gem 'elastic-apm'
gem 'flipper'
gem 'flipper-active_record'

View file

@ -218,7 +218,14 @@ GEM
dotenv (= 2.7.6)
railties (>= 3.2)
dry-cli (1.0.0)
dry-core (1.0.0)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (0.2.0)
dry-monads (1.6.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dumb_delegator (1.0.0)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
@ -828,6 +835,7 @@ DEPENDENCIES
devise-two-factor
discard
dotenv-rails
dry-monads
elastic-apm
factory_bot
flipper

View file

@ -48,6 +48,8 @@
= render partial: "shared/champs/rna/show", locals: { champ: champ, profile: @profile }
- when TypeDeChamp.type_champs.fetch(:epci)
= render partial: "shared/champs/epci/show", locals: { champ: champ }
- when TypeDeChamp.type_champs.fetch(:cojo)
= render partial: "shared/champs/cojo/show", locals: { champ: champ, profile: @profile }
- when TypeDeChamp.type_champs.fetch(:date)
%p= champ.to_s
- when TypeDeChamp.type_champs.fetch(:datetime)

View file

@ -0,0 +1,9 @@
class EditableChamp::COJOComponent < EditableChamp::EditableChampBaseComponent
def input_group_class
if @champ.accreditation_success?
'fr-input-group--valid'
elsif @champ.accreditation_error?
'fr-input-group--error'
end
end
end

View file

@ -0,0 +1,7 @@
---
en:
accreditation_number_label: Accreditation number
accreditation_number_notice: Identification number issued by Paris 2024
accreditation_birthdate_label: Date of birth
accreditation_number_error: Invalid accreditation number
accreditation_number_verification_pending: Accreditation number verification in progress

View file

@ -0,0 +1,7 @@
---
fr:
accreditation_number_label: Numéro daccréditation
accreditation_number_notice: Numéro didentification délivré par Paris 2024
accreditation_birthdate_label: Date de naissance
accreditation_number_error: Le numéro daccréditation est incorrect
accreditation_number_verification_pending: Vérification du numéro daccréditation en cours

View file

@ -0,0 +1,23 @@
.fr-input-group{ class: input_group_class }
= @form.label :accreditation_number, for: @champ.accreditation_number_input_id, class: 'fr-label' do
- safe_join [t('.accreditation_number_label'), @champ.required? ? render(EditableChamp::AsteriskMandatoryComponent.new) : ''], ' '
%p.fr-hint-text{ id: dom_id(@champ, :accreditation_number_notice) }= t('.accreditation_number_notice')
= @form.text_field :accreditation_number,
required: @champ.required?,
aria: { describedby: [dom_id(@champ, :accreditation_number_notice), @champ.accreditation_error? ? dom_id(@champ, :accreditation_number_error) : nil].compact.join(' ') },
data: { controller: 'format', format: 'integer' },
class: "width-33-desktop fr-input small-margin", id: @champ.accreditation_number_input_id
- if @champ.accreditation_error?
%p.fr-error-text{ id: dom_id(@champ, :accreditation_number_error) }= t('.accreditation_number_error')
- elsif @champ.fetch_external_data_pending?
%p.fr-info-text= t('.accreditation_number_verification_pending')
.fr-input-group{ class: input_group_class }
= @form.label :accreditation_birthdate, for: @champ.accreditation_birthdate_input_id, class: 'fr-label' do
- safe_join [t('.accreditation_birthdate_label'), @champ.required? ? render(EditableChamp::AsteriskMandatoryComponent.new) : ''], ' '
= @form.date_field :accreditation_birthdate,
required: @champ.required?,
aria: { describedby: dom_id(@champ, :accreditation_birthdate) },
class: "width-33-desktop fr-input small-margin", id: @champ.accreditation_birthdate_input_id

View file

@ -25,15 +25,35 @@ class EditableChamp::EditableChampComponent < ApplicationComponent
"hidden": !@champ.visible?
),
id: @champ.input_group_id,
data: { controller: stimulus_controller, **data_dependent_conditions }
data: { controller: stimulus_controller, **data_dependent_conditions, **stimulus_values }
}
end
def stimulus_values
if @champ.fetch_external_data_pending?
{ turbo_poll_url_value: }
else
{}
end
end
def turbo_poll_url_value
if @champ.private?
annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ)
else
champ_dossier_path(@champ.dossier, @champ)
end
end
def stimulus_controller
if autosave_enabled?
# This is an editable champ. Lets find what controllers it might need.
controllers = ['autosave']
if @champ.fetch_external_data_pending?
controllers << 'turbo-poll'
end
controllers.join(' ')
end
end

View file

@ -93,8 +93,8 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent
end
def filter_featured_type_champ(type_champ)
feature_name = TypeDeChamp::FEATURE_FLAGS[type_champ]
feature_name.blank? || feature_enabled?(feature_name)
feature_name = TypeDeChamp::FEATURE_FLAGS[type_champ.to_sym]
feature_name.blank? || procedure.feature_enabled?(feature_name)
end
def filter_type_champ(type_champ)

View file

@ -302,6 +302,20 @@ module Instructeurs
render layout: "print"
end
def annotation
@dossier = dossier_with_champs(pj_template: false)
annotation = @dossier.champs_private_all.find(params[:annotation_id])
respond_to do |format|
format.turbo_stream do
@to_show, @to_hide = []
@to_update = [annotation]
render :update_annotations
end
end
end
def telecharger_pjs
files = ActiveStorage::DownloadableFile.create_list_from_dossiers(Dossier.where(id: dossier.id), with_champs_private: true, include_infos_administration: true)
cleaned_files = ActiveStorage::DownloadableFile.cleanup_list_from_dossier(files)

View file

@ -6,11 +6,11 @@ module Users
layout 'procedure_context', only: [:identite, :update_identite, :siret, :update_siret]
ACTIONS_ALLOWED_TO_ANY_USER = [:index, :recherche, :new, :transferer_all]
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :create_commentaire, :papertrail, :restore]
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :create_commentaire, :papertrail, :restore, :champ]
before_action :ensure_ownership!, except: ACTIONS_ALLOWED_TO_ANY_USER + ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
before_action :ensure_ownership_or_invitation!, only: ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update]
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :champ]
before_action :ensure_dossier_can_be_filled, only: [:brouillon, :modifier, :submit_brouillon, :submit_en_construction, :update]
before_action :ensure_dossier_can_be_viewed, only: [:show]
before_action :forbid_invite_submission!, only: [:submit_brouillon]
@ -274,6 +274,20 @@ module Users
@dossier = current_user.dossiers.includes(:procedure).find(params[:id])
end
def champ
@dossier = dossier_with_champs(pj_template: false)
champ = @dossier.champs_public_all.find(params[:champ_id])
respond_to do |format|
format.turbo_stream do
@to_show, @to_hide = []
@to_update = [champ]
render :update, layout: false
end
end
end
def create_commentaire
@commentaire = CommentaireService.create(current_user, dossier, commentaire_params)
@ -457,15 +471,30 @@ module Users
def champs_public_params
champs_params = params.require(:dossier).permit(champs_public_attributes: [
:id, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :code_departement, value: [],
champs_attributes: [:id, :_destroy, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :departement, :code_departement, value: []]
:id,
:value,
:value_other,
:external_id,
:primary_value,
:secondary_value,
:numero_allocataire,
:code_postal,
:identifiant,
:numero_fiscal,
:reference_avis,
:ine,
:piece_justificative_file,
:code_departement,
:accreditation_number,
:accreditation_birthdate,
value: []
])
champs_params[:champs_public_all_attributes] = champs_params.delete(:champs_public_attributes) || {}
champs_params
end
def dossier_scope
if action_name == 'update'
if action_name == 'update' || action_name == 'champ'
Dossier.visible_by_user.or(Dossier.for_procedure_preview).or(Dossier.for_editing_fork)
elsif action_name == 'restore'
Dossier.hidden_by_user

View file

@ -84,6 +84,7 @@ class API::V2::Schema < GraphQL::Schema
Types::Champs::Descriptor::CheckboxChampDescriptorType,
Types::Champs::Descriptor::CiviliteChampDescriptorType,
Types::Champs::Descriptor::CnafChampDescriptorType,
Types::Champs::Descriptor::COJOChampDescriptorType,
Types::Champs::Descriptor::CommuneChampDescriptorType,
Types::Champs::Descriptor::DateChampDescriptorType,
Types::Champs::Descriptor::DatetimeChampDescriptorType,

View file

@ -190,6 +190,34 @@ exceed the size of a 32-bit integer, it's encoded as a string.
"""
scalar BigInt
type COJOChampDescriptor implements ChampDescriptor {
"""
Description des champs dun bloc répétable.
"""
champDescriptors: [ChampDescriptor!] @deprecated(reason: "Utilisez le champ `RepetitionChampDescriptor.champ_descriptors` à la place.")
"""
Description du champ.
"""
description: String
id: ID!
"""
Libellé du champ.
"""
label: String!
"""
Est-ce que le champ est obligatoire ?
"""
required: Boolean!
"""
Type de la valeur du champ.
"""
type: TypeDeChamp! @deprecated(reason: "Utilisez le champ `__typename` à la place.")
}
type CarteChamp implements Champ {
geoAreas: [GeoArea!]!
id: ID!
@ -3699,6 +3727,11 @@ enum TypeDeChamp {
"""
cnaf
"""
Accréditation Paris 2024
"""
cojo
"""
Communes
"""

View file

@ -94,6 +94,8 @@ module Types
Types::Champs::Descriptor::MesriChampDescriptorType
when TypeDeChamp.type_champs.fetch(:epci)
Types::Champs::Descriptor::EpciChampDescriptorType
when TypeDeChamp.type_champs.fetch(:cojo)
Types::Champs::Descriptor::COJOChampDescriptorType
end
end
end

View file

@ -0,0 +1,5 @@
module Types::Champs::Descriptor
class COJOChampDescriptorType < Types::BaseObject
implements Types::ChampDescriptorType
end
end

View file

@ -15,6 +15,12 @@ export class FormatController extends ApplicationController {
const target = event.target as HTMLInputElement;
target.value = this.formatIBAN(target.value);
});
break;
case 'integer':
this.on('input', (event) => {
const target = event.target as HTMLInputElement;
target.value = this.formatInteger(target.value);
});
}
}
@ -28,4 +34,8 @@ export class FormatController extends ApplicationController {
.replace(/(.{4})/g, '$1 ')
.trim();
}
private formatInteger(value: string) {
return value.replace(/[^\d]/g, '');
}
}

View file

@ -1,9 +1,28 @@
class ChampFetchExternalDataJob < ApplicationJob
include Dry::Monads[:result]
def perform(champ, external_id)
return if champ.external_id != external_id
return if champ.data.present?
return if (data = champ.fetch_external_data).blank?
champ.update_with_external_data!(data: data)
Sentry.set_tags(champ: champ.id)
Sentry.set_extras(external_id:)
result = champ.fetch_external_data
if result.is_a?(Dry::Monads::Result)
case result
in Success(data)
champ.update_with_external_data!(data:)
in Failure(retryable: true, reason:)
champ.log_fetch_external_data_exception(reason)
throw reason
in Failure(retryable: false, reason:)
champ.log_fetch_external_data_exception(reason)
Sentry.capture_exception(reason)
end
elsif result.present?
champ.update_with_external_data!(data: result)
end
end
end

97
app/lib/api/client.rb Normal file
View file

@ -0,0 +1,97 @@
class API::Client
include Dry::Monads[:result]
TIMEOUT = 10
def call(url:, params: nil, body: nil, json: nil, headers: nil, method: :get, authorization_token: nil, schema: nil, timeout: TIMEOUT)
response = case method
when :get
Typhoeus.get(url,
headers: headers_with_authorization(headers, authorization_token),
params:,
timeout: TIMEOUT)
when :post
Typhoeus.post(url,
headers: headers_with_authorization(headers, json, authorization_token),
body: json.nil? ? body : json.to_json,
timeout: TIMEOUT)
end
handle_response(response, schema:)
end
private
def headers_with_authorization(headers, json, authorization_token)
headers = headers || {}
headers['authorization'] = "Bearer #{authorization_token}" if authorization_token.present?
headers['content-type'] = 'application/json' if json.present?
headers
end
OK = Data.define(:body, :response)
Error = Data.define(:type, :code, :retryable, :reason)
def handle_response(response, schema:)
if response.success?
scope = Sentry.get_current_scope
if scope.extra.key?(:external_id)
scope.set_extras(raw_body: response.body.to_s)
end
body = parse_body(response.body)
case body
in Success(body)
if !schema || schema.valid?(body)
Success(OK[body.deep_symbolize_keys, response])
else
Failure(Error[:schema, response.code, false, SchemaError.new(schema.validate(body))])
end
in Failure(reason)
Failure(Error[:json, response.code, false, reason])
end
elsif response.timed_out?
Failure(Error[:timeout, response.code, true, HTTPError.new(response)])
elsif response.code != 0
Failure(Error[:http, response.code, true, HTTPError.new(response)])
else
Failure(Error[:network, response.code, true, HTTPError.new(response)])
end
end
def parse_body(body)
Success(JSON.parse(body))
rescue JSON::ParserError => error
Failure(error)
end
class SchemaError < StandardError
attr_reader :errors
def initialize(errors)
@errors = errors.to_a
super(@errors.map(&:to_json).join("\n"))
end
end
class HTTPError < StandardError
attr_reader :response
def initialize(response)
@response = response
uri = URI.parse(response.effective_url)
msg = <<~TEXT
url: #{uri.host}#{uri.path}
HTTP error code: #{response.code}
body: #{CGI.escape(response.body)}
curl message: #{response.return_message}
total time: #{response.total_time}
connect time: #{response.connect_time}
response headers: #{response.headers}
TEXT
super(msg)
end
end
end

View file

@ -223,6 +223,16 @@ class Champ < ApplicationRecord
false
end
def poll_external_data?
false
end
def fetch_external_data_pending?
# We don't have a good mechanism right now to know if the last fetch has errored. So, in order
# to ensure we don't poll to infinity, we stop after 5 minutes no matter what.
fetch_external_data? && poll_external_data? && external_id.present? && data.nil? && updated_at > 5.minutes.ago
end
def fetch_external_data
raise NotImplemented.new(:fetch_external_data)
end

View file

@ -0,0 +1,64 @@
class Champs::COJOChamp < Champ
store_accessor :value_json, :accreditation_number, :accreditation_birthdate
store_accessor :data, :accreditation_success, :accreditation_first_name, :accreditation_last_name
after_validation :update_external_id
def accreditation_birthdate
Date.parse(super)
rescue ArgumentError, TypeError
nil
end
def accreditation_success?
accreditation_success == true
end
def accreditation_error?
accreditation_success == false
end
def blank?
accreditation_success.nil?
end
def fetch_external_data?
true
end
def poll_external_data?
true
end
def fetch_external_data
COJOService.new.(accreditation_number:, accreditation_birthdate:)
end
def to_s
"#{accreditation_number} #{accreditation_birthdate}"
end
def accreditation_number_input_id
"#{input_id}-accreditation_number"
end
def accreditation_birthdate_input_id
"#{input_id}-accreditation_birthdate"
end
def focusable_input_id
accreditation_number_input_id
end
private
def update_external_id
if accreditation_number_changed? || accreditation_birthdate_changed?
if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number)
self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json
else
self.external_id = nil
end
end
end
end

View file

@ -18,7 +18,9 @@ class TypeDeChamp < ApplicationRecord
self.ignored_columns += [:migrated_parent, :revision_id, :parent_id, :order_place]
FILE_MAX_SIZE = 200.megabytes
FEATURE_FLAGS = {}
FEATURE_FLAGS = {
cojo: :cojo_type_de_champ
}
MINIMUM_TEXTAREA_CHARACTER_LIMIT_LENGTH = 400
STRUCTURE = :structure
@ -68,7 +70,8 @@ class TypeDeChamp < ApplicationRecord
cnaf: REFERENTIEL_EXTERNE,
dgfip: REFERENTIEL_EXTERNE,
pole_emploi: REFERENTIEL_EXTERNE,
mesri: REFERENTIEL_EXTERNE
mesri: REFERENTIEL_EXTERNE,
cojo: REFERENTIEL_EXTERNE
}
enum type_champs: {
@ -107,7 +110,8 @@ class TypeDeChamp < ApplicationRecord
dgfip: 'dgfip',
pole_emploi: 'pole_emploi',
mesri: 'mesri',
epci: 'epci'
epci: 'epci',
cojo: 'cojo'
}
store_accessor :options,
@ -569,7 +573,8 @@ class TypeDeChamp < ApplicationRecord
type_champs.fetch(:dossier_link),
type_champs.fetch(:linked_drop_down_list),
type_champs.fetch(:drop_down_list),
type_champs.fetch(:textarea)
type_champs.fetch(:textarea),
type_champs.fetch(:cojo)
true
else
false

View file

@ -0,0 +1,2 @@
class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp
end

View file

@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://demarches-simplifiees.fr/accreditation-cojo.schema.json",
"title": "Accreditation COJO",
"type": "object",
"properties": {
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"individualExistance": {
"enum": ["Yes", "No"]
}
},
"required": ["individualExistance"]
}

View file

@ -0,0 +1,56 @@
class COJOService
include Dry::Monads[:result]
def call(accreditation_number:, accreditation_birthdate:)
result = API::Client.new.(url:,
json: {
accreditationNumber: accreditation_number.to_i,
birthdate: accreditation_birthdate&.strftime('%d/%m/%Y')
},
authorization_token:,
schema:,
method: :post)
case result
in Success(body:)
accreditation_success = body[:individualExistance] == 'Yes'
Success({
accreditation_success:,
accreditation_first_name: accreditation_success ? body[:firstName] : nil,
accreditation_last_name: accreditation_success ? body[:lastName] : nil
})
in Failure(code:, reason:) if code.in?(401..403)
Failure(API::Client::Error[:unauthorized, code, false, reason])
else
result
end
end
private
def schema
JSONSchemer.schema(Rails.root.join('app/schemas/accreditation-cojo.json'))
end
def url
"#{API_COJO_URL}/api/accreditation"
end
def authorization_token
rsa_private_key&.then { JWT.encode(jwt_payload, _1, 'RS256') }
end
def jwt_payload
{
iss: APPLICATION_NAME,
iat: Time.zone.now.to_i,
exp: 1.hour.from_now.to_i
}
end
def rsa_private_key
if ENV['COJO_JWT_RSA_PRIVATE_KEY'].present?
OpenSSL::PKey::RSA.new(ENV['COJO_JWT_RSA_PRIVATE_KEY'])
end
end
end

View file

@ -0,0 +1,9 @@
= champ.to_s
- if profile == 'instructeur'
%dl
%dt
Nom et prénom dans la base daccréditation :
%dd= "#{champ.accreditation_last_name} #{champ.accreditation_first_name}"
%dt
Nom et prénom saisie dans le dossier :
%dd= "#{champ.dossier.individual.nom} #{champ.dossier.individual.prenom}"

View file

@ -201,3 +201,9 @@ BANNER_MESSAGE=""
ADMINISTRATION_BANNER_MESSAGE=""
# for usager only
USAGER_BANNER_MESSAGE=""
# RSA private key to generate JWT tokens for communication with COJO services
COJO_JWT_RSA_PRIVATE_KEY=""
COJO_JWT_ISS=""
API_COJO_URL=""

View file

@ -6,6 +6,7 @@ API_EDUCATION_URL = ENV.fetch("API_EDUCATION_URL", "https://data.education.gouv.
API_GEO_URL = ENV.fetch("API_GEO_URL", "https://geo.api.gouv.fr")
API_PARTICULIER_URL = ENV.fetch("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api")
API_TCHAP_URL = ENV.fetch("API_TCHAP_URL", "https://matrix.agent.tchap.gouv.fr/_matrix/identity/api/v1")
API_COJO_URL = ENV.fetch("API_COJO_URL", nil)
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")
PIPEDRIVE_API_URL = ENV.fetch("PIPEDRIVE_API_URL", "https://api.pipedrive.com/v1")
SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2")

View file

@ -18,7 +18,8 @@ features = [
:hide_instructeur_email,
:procedure_routage_api,
:groupe_instructeur_api_hack,
:rerouting
:rerouting,
:cojo_type_de_champ
]
def database_exists?

View file

@ -8,6 +8,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.singular /^(ox)en/i, '\1'
# inflect.irregular 'person', 'people'
# inflect.uncountable %w( fish sheep )
inflect.acronym 'COJO'
inflect.acronym 'API'
inflect.acronym 'ASN1'
inflect.acronym 'IP'

View file

@ -50,6 +50,7 @@ en:
pole_emploi: 'Pôle emploi status'
mesri: "Data from Ministère de lEnseignement Supérieur, de la Recherche et de lInnovation"
epci: "EPCI"
cojo: "Accreditation Paris 2024"
errors:
type_de_champ:
attributes:

View file

@ -50,6 +50,7 @@ fr:
pole_emploi: 'Situation Pôle emploi'
mesri: "Données du Ministère de lEnseignement Supérieur, de la Recherche et de lInnovation"
epci: "EPCI"
cojo: "Accréditation Paris 2024"
errors:
type_de_champ:
attributes:

View file

@ -322,6 +322,7 @@ Rails.application.routes.draw do
get 'modifier', to: 'dossiers#modifier'
post 'modifier', to: 'dossiers#submit_en_construction'
patch 'modifier', to: 'dossiers#modifier_legacy'
get 'champs/:champ_id', to: 'dossiers#champ', as: :champ
get 'merci'
get 'demande'
get 'messagerie'
@ -434,6 +435,7 @@ Rails.application.routes.draw do
get 'avis'
get 'avis_new'
get 'personnes-impliquees' => 'dossiers#personnes_impliquees'
get 'annotations/:annotation_id', to: 'dossiers#annotation', as: :annotation
patch 'follow'
patch 'unfollow'
patch 'archive'

View file

@ -239,6 +239,10 @@ FactoryBot.define do
value { 'W173847273' }
end
factory :champ_cojo, class: 'Champs::COJOChamp' do
type_de_champ { association :type_de_champ_cojo, procedure: dossier.procedure }
end
factory :champ_repetition, class: 'Champs::RepetitionChamp' do
type_de_champ { association :type_de_champ_repetition, procedure: dossier.procedure }

View file

@ -181,6 +181,9 @@ FactoryBot.define do
factory :type_de_champ_epci do
type_champ { TypeDeChamp.type_champs.fetch(:epci) }
end
factory :type_de_champ_cojo do
type_champ { TypeDeChamp.type_champs.fetch(:cojo) }
end
factory :type_de_champ_repetition do
type_champ { TypeDeChamp.type_champs.fetch(:repetition) }

View file

@ -0,0 +1,4 @@
{
"individualExistance": "maybe",
"firstName": "Florence"
}

View file

@ -0,0 +1,5 @@
{
"individualExistance": "No",
"firstName": "Not found",
"lastName": "Not found"
}

View file

@ -0,0 +1,5 @@
{
"individualExistance": "Yes",
"firstName": "Florence",
"lastName": "Griffith-Joyner"
}

View file

@ -3,17 +3,21 @@
require 'rails_helper'
RSpec.describe ChampFetchExternalDataJob, type: :job do
let(:champ) { Struct.new(:external_id, :data).new(champ_external_id, data) }
let(:champ) { build(:champ, external_id: champ_external_id, data:) }
let(:external_id) { "an ID" }
let(:champ_external_id) { "an ID" }
let(:data) { nil }
let(:fetched_data) { nil }
let(:reason) { StandardError.new("error") }
subject(:perform_job) { described_class.perform_now(champ, external_id) }
include Dry::Monads[:result]
before do
allow(champ).to receive(:fetch_external_data).and_return(fetched_data)
allow(champ).to receive(:update_with_external_data!)
allow(champ).to receive(:log_fetch_external_data_exception)
end
shared_examples "a champ non-updater" do
@ -38,6 +42,35 @@ RSpec.describe ChampFetchExternalDataJob, type: :job do
end
end
context 'when the fetched data is a result' do
context 'success' do
let(:fetched_data) { Success("data") }
it 'updates the champ' do
perform_job
expect(champ).to have_received(:update_with_external_data!).with(data: fetched_data.value!)
end
end
context 'retryable failure' do
let(:fetched_data) { Failure(API::Client::Error[:http, 400, true, reason]) }
it 'saves exception and raise' do
expect { perform_job }.to raise_error StandardError
expect(champ).to have_received(:log_fetch_external_data_exception).with(reason)
end
end
context 'fatal failure' do
let(:fetched_data) { Failure(API::Client::Error[:http, 400, false, reason]) }
it 'saves exception' do
perform_job
expect(champ).to have_received(:log_fetch_external_data_exception).with(reason)
end
end
end
context 'when the fetched data is blank' do
it_behaves_like "a champ non-updater"
end

View file

@ -14,9 +14,9 @@ describe '20220705164551_remove_unused_champs' do
describe 'remove_unused_champs' do
it "with bad champs" do
expect(Champ.where(dossier: dossier).count).to eq(40)
expect(Champ.where(dossier: dossier).count).to eq(41)
run_task
expect(Champ.where(dossier: dossier).count).to eq(39)
expect(Champ.where(dossier: dossier).count).to eq(40)
end
end
end

View file

@ -0,0 +1,50 @@
describe Champs::COJOChamp, type: :model do
let(:champ) { build(:champ_cojo, accreditation_number:, accreditation_birthdate:) }
let(:external_id) { nil }
let(:stub) { stub_request(:post, url).with(body: { accreditationNumber: accreditation_number, birthdate: accreditation_birthdate }).to_return(body:, status:) }
let(:url) { COJOService.new.send(:url) }
let(:body) { Rails.root.join('spec', 'fixtures', 'files', 'api_cojo', "accreditation_#{response_type}.json").read }
let(:status) { 200 }
let(:response_type) { 'yes' }
let(:accreditation_number) { 123456 }
let(:accreditation_birthdate) { '21/12/1959' }
describe 'fetch_external_data' do
subject { stub; champ.fetch_external_data }
context 'success (yes)' do
it { expect(subject.value!).to eq({ accreditation_success: true, accreditation_first_name: 'Florence', accreditation_last_name: 'Griffith-Joyner' }) }
end
context 'success (no)' do
let(:response_type) { 'no' }
it { expect(subject.value!).to eq({ accreditation_success: false, accreditation_first_name: nil, accreditation_last_name: nil }) }
end
context 'failure (schema)' do
let(:response_type) { 'invalid' }
it {
expect(subject.failure.retryable).to be_falsey
expect(subject.failure.reason).to be_a(API::Client::SchemaError)
}
end
context 'failure (http 500)' do
let(:status) { 500 }
let(:response_type) { 'invalid' }
it {
expect(subject.failure.retryable).to be_truthy
expect(subject.failure.reason).to be_a(API::Client::HTTPError)
}
end
context 'failure (http 401)' do
let(:status) { 401 }
let(:response_type) { 'invalid' }
it {
expect(subject.failure.retryable).to be_falsey
expect(subject.failure.reason).to be_a(API::Client::HTTPError)
}
end
end
end

View file

@ -88,7 +88,8 @@ describe ProcedureExportService do
"text",
"epci",
"epci (Code)",
"epci (Département)"
"epci (Département)",
"cojo"
]
end
@ -198,7 +199,8 @@ describe ProcedureExportService do
"text",
"epci",
"epci (Code)",
"epci (Département)"
"epci (Département)",
"cojo"
]
end
@ -291,7 +293,8 @@ describe ProcedureExportService do
"text",
"epci",
"epci (Code)",
"epci (Département)"
"epci (Département)",
"cojo"
]
end