diff --git a/app/commands/decidim/direct_verifications/verification/create_import.rb b/app/commands/decidim/direct_verifications/verification/create_import.rb new file mode 100644 index 0000000..186523f --- /dev/null +++ b/app/commands/decidim/direct_verifications/verification/create_import.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + module Verification + class CreateImport < Rectify::Command + def initialize(form) + @form = form + @file = form.file + @organization = form.organization + @user = form.user + @action = form.action + end + + def call + return broadcast(:invalid) unless form.valid? + + case action + when :register + register_users_async + when :register_and_authorize + register_users_async + file.rewind + authorize_users_async + when :revoke + revoke_users_async + end + + broadcast(:ok) + end + + private + + attr_reader :form, :file, :organization, :user, :action + + def register_users_async + RegisterUsersJob.perform_later(file.read, organization, user, form.authorization_handler) + end + + def revoke_users_async + RevokeUsersJob.perform_later(file.read, organization, user, form.authorization_handler) + end + + def authorize_users_async + AuthorizeUsersJob.perform_later(file.read, organization, user, form.authorization_handler) + end + end + end + end +end diff --git a/app/commands/decidim/direct_verifications/verification/metadata_parser.rb b/app/commands/decidim/direct_verifications/verification/metadata_parser.rb index e5ee82e..e8cb541 100644 --- a/app/commands/decidim/direct_verifications/verification/metadata_parser.rb +++ b/app/commands/decidim/direct_verifications/verification/metadata_parser.rb @@ -9,8 +9,8 @@ class MetadataParser < BaseParser def header @header ||= begin header_row = lines[0].chomp - column_names = tokenize(header_row) - column_names.map(&:to_sym).map(&:downcase) + header_row = tokenize(header_row) + normalize_header(header_row) end end @@ -23,8 +23,8 @@ def parse_data(email, line, header) hash = {} header.each_with_index do |column, index| - value = tokens[index].strip - next if value.include?(email) + value = tokens[index] + next if value&.include?(email) hash[column] = value end @@ -34,7 +34,17 @@ def parse_data(email, line, header) private def tokenize(line) - CSV.parse(line)[0] + CSV.parse_line(line).map do |token| + token&.strip + end + end + + def normalize_header(line) + line.map do |field| + raise MissingHeaderError if field.nil? + + field.to_sym.downcase + end end end end diff --git a/app/commands/decidim/direct_verifications/verification/missing_header_error.rb b/app/commands/decidim/direct_verifications/verification/missing_header_error.rb new file mode 100644 index 0000000..296b39c --- /dev/null +++ b/app/commands/decidim/direct_verifications/verification/missing_header_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + module Verification + class MissingHeaderError < StandardError + end + end + end +end diff --git a/app/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller.rb b/app/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller.rb index 3d1571f..3661732 100644 --- a/app/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller.rb +++ b/app/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller.rb @@ -10,6 +10,8 @@ class DirectVerificationsController < Decidim::Admin::ApplicationController layout "decidim/admin/users" + I18N_SCOPE = "decidim.direct_verifications.verification.admin.direct_verifications" + def index enforce_permission_to :index, :authorization end @@ -18,29 +20,39 @@ def create enforce_permission_to :create, :authorization @userslist = params[:userslist] - @processor = UserProcessor.new(current_organization, current_user, session) + + @processor = UserProcessor.new(current_organization, current_user, session, instrumenter) @processor.emails = parser_class.new(@userslist).to_h @processor.authorization_handler = current_authorization_handler + @stats = UserStats.new(current_organization) @stats.authorization_handler = @processor.authorization_handler + register_users authorize_users revoke_users render(action: :index) && return if show_users_info + redirect_to direct_verifications_path + rescue MissingHeaderError => _e + flash[:error] = I18n.t("#{I18N_SCOPE}.create.missing_header") redirect_to direct_verifications_path end private + def instrumenter + @instrumenter ||= Instrumenter.new(current_user) + end + def register_users return unless params[:register] @processor.register_users flash[:warning] = t(".registered", count: @processor.emails.count, - registered: @processor.processed[:registered].count, - errors: @processor.errors[:registered].count) + registered: instrumenter.processed_count(:registered), + errors: instrumenter.errors_count(:registered)) end def authorize_users @@ -49,8 +61,8 @@ def authorize_users @processor.authorize_users flash[:notice] = t(".authorized", handler: t("#{@processor.authorization_handler}.name", scope: "decidim.authorization_handlers"), count: @processor.emails.count, - authorized: @processor.processed[:authorized].count, - errors: @processor.errors[:authorized].count) + authorized: instrumenter.processed_count(:authorized), + errors: instrumenter.errors_count(:authorized)) end def revoke_users @@ -59,8 +71,8 @@ def revoke_users @processor.revoke_users flash[:notice] = t(".revoked", handler: t("#{@processor.authorization_handler}.name", scope: "decidim.authorization_handlers"), count: @processor.emails.count, - revoked: @processor.processed[:revoked].count, - errors: @processor.errors[:revoked].count) + revoked: instrumenter.processed_count(:revoked), + errors: instrumenter.errors_count(:revoked)) end def show_users_info diff --git a/app/controllers/decidim/direct_verifications/verification/admin/imports_controller.rb b/app/controllers/decidim/direct_verifications/verification/admin/imports_controller.rb new file mode 100644 index 0000000..8f6321d --- /dev/null +++ b/app/controllers/decidim/direct_verifications/verification/admin/imports_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + module Verification + module Admin + class ImportsController < Decidim::Admin::ApplicationController + layout "decidim/admin/users" + helper_method :workflows, :current_authorization_handler + + def new + enforce_permission_to :create, :authorization + @form = form(CreateImportForm).instance + end + + def create + enforce_permission_to :create, :authorization + + defaults = { organization: current_organization, user: current_user } + form = form(CreateImportForm).from_params(params.merge(defaults)) + + CreateImport.call(form) do + on(:ok) do + flash[:notice] = t(".success") + end + + on(:invalid) do + flash[:alert] = t(".error") + end + end + + redirect_to new_import_path + end + + def workflows + workflows = configured_workflows & current_organization.available_authorizations.map.to_a + workflows.map do |workflow| + [t("#{workflow}.name", scope: "decidim.authorization_handlers"), workflow] + end + end + + def configured_workflows + return Decidim::DirectVerifications.config.manage_workflows if Decidim::DirectVerifications.config + + ["direct_verifications"] + end + + def current_authorization_handler + params[:authorization_handler] + end + end + end + end + end +end diff --git a/app/forms/decidim/direct_verifications/verification/create_import_form.rb b/app/forms/decidim/direct_verifications/verification/create_import_form.rb new file mode 100644 index 0000000..a68bf6b --- /dev/null +++ b/app/forms/decidim/direct_verifications/verification/create_import_form.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + module Verification + class CreateImportForm < Form + ACTIONS = { + "in" => :authorize, + "out" => :revoke, + "check" => :check + }.freeze + + attribute :file + attribute :organization, Decidim::Organization + attribute :user, Decidim::User + attribute :authorize, String + attribute :register, Boolean + attribute :authorization_handler, String + + validates :file, :organization, :user, :authorize, :authorization_handler, presence: true + validates :authorize, inclusion: { in: ACTIONS.keys } + + validate :available_authorization_handler + + def available_authorization_handler + return if authorization_handler.in?(organization.available_authorizations) + + errors.add(:authorization_handler, :inclusion) + end + + def action + if register && authorize == "in" + :register_and_authorize + elsif register + :register + else + ACTIONS[authorize] + end + end + end + end + end +end diff --git a/app/jobs/decidim/direct_verifications/authorize_users_job.rb b/app/jobs/decidim/direct_verifications/authorize_users_job.rb new file mode 100644 index 0000000..8f68c24 --- /dev/null +++ b/app/jobs/decidim/direct_verifications/authorize_users_job.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "decidim/direct_verifications/instrumenter" + +module Decidim + module DirectVerifications + class AuthorizeUsersJob < BaseImportJob + class NullSession; end + + def process_users + emails.each do |email, data| + AuthorizeUser.new( + email, + data, + session, + organization, + instrumenter, + authorization_handler + ).call + end + end + + def type + :authorized + end + + private + + def session + NullSession.new + end + end + end +end diff --git a/app/jobs/decidim/direct_verifications/base_import_job.rb b/app/jobs/decidim/direct_verifications/base_import_job.rb new file mode 100644 index 0000000..5fc8c1a --- /dev/null +++ b/app/jobs/decidim/direct_verifications/base_import_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "decidim/direct_verifications/instrumenter" + +module Decidim + module DirectVerifications + # This class implements the logic to import the user entries and sending an email notification + # with the result. The specifics to process the entries are meant to be implemented by + # subclasses which must implement the `#process_users` and `#type` methods. + class BaseImportJob < ApplicationJob + queue_as :default + + def perform(userslist, organization, current_user, authorization_handler) + @emails = Verification::MetadataParser.new(userslist).to_h + @organization = organization + @current_user = current_user + @instrumenter = Instrumenter.new(current_user) + @authorization_handler = authorization_handler + + process_users + send_email_notification + end + + private + + attr_reader :emails, :organization, :current_user, :instrumenter, :authorization_handler + + def send_email_notification + ImportMailer.finished_processing( + current_user, + instrumenter, + type, + authorization_handler + ).deliver_now + end + end + end +end diff --git a/app/jobs/decidim/direct_verifications/register_users_job.rb b/app/jobs/decidim/direct_verifications/register_users_job.rb new file mode 100644 index 0000000..8386087 --- /dev/null +++ b/app/jobs/decidim/direct_verifications/register_users_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "decidim/direct_verifications/instrumenter" + +module Decidim + module DirectVerifications + class RegisterUsersJob < BaseImportJob + def process_users + emails.each do |email, data| + RegisterUser.new(email, data, organization, current_user, instrumenter).call + end + end + + def type + :registered + end + end + end +end diff --git a/app/jobs/decidim/direct_verifications/revoke_users_job.rb b/app/jobs/decidim/direct_verifications/revoke_users_job.rb new file mode 100644 index 0000000..b4ae3a5 --- /dev/null +++ b/app/jobs/decidim/direct_verifications/revoke_users_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class RevokeUsersJob < BaseImportJob + def process_users + emails.each do |email, _name| + RevokeUser.new(email, organization, instrumenter, authorization_handler).call + end + end + + def type + :revoked + end + end + end +end diff --git a/app/mailers/decidim/direct_verifications/import_mailer.rb b/app/mailers/decidim/direct_verifications/import_mailer.rb new file mode 100644 index 0000000..beaf80d --- /dev/null +++ b/app/mailers/decidim/direct_verifications/import_mailer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class ImportMailer < Decidim::Admin::ApplicationMailer + include LocalisedMailer + + layout "decidim/mailer" + + I18N_SCOPE = "decidim.direct_verifications.verification.admin.imports.mailer" + + def finished_processing(user, instrumenter, type, handler) + @stats = Stats.from(instrumenter, type) + @organization = user.organization + @i18n_key = "#{I18N_SCOPE}.#{type}" + @handler = handler + + with_user(user) do + mail( + to: user.email, + subject: I18n.t("#{I18N_SCOPE}.subject") + ) + end + end + end + end +end diff --git a/app/mailers/decidim/direct_verifications/stats.rb b/app/mailers/decidim/direct_verifications/stats.rb new file mode 100644 index 0000000..d8e1869 --- /dev/null +++ b/app/mailers/decidim/direct_verifications/stats.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class Stats + attr_reader :count, :successful, :errors + + def self.from(instrumenter, type) + new( + count: instrumenter.emails_count(type), + successful: instrumenter.processed_count(type), + errors: instrumenter.errors_count(type) + ) + end + + def initialize(count:, successful:, errors:) + @count = count + @successful = successful + @errors = errors + end + end + end +end diff --git a/app/views/decidim/direct_verifications/import_mailer/finished_processing.html.erb b/app/views/decidim/direct_verifications/import_mailer/finished_processing.html.erb new file mode 100644 index 0000000..7c0eefe --- /dev/null +++ b/app/views/decidim/direct_verifications/import_mailer/finished_processing.html.erb @@ -0,0 +1,7 @@ +

<%= t( + @i18n_key, + count: @stats.count, + successful: @stats.successful, + errors: @stats.errors, + handler: @handler +) %>

diff --git a/app/views/decidim/direct_verifications/import_mailer/finished_processing.text.erb b/app/views/decidim/direct_verifications/import_mailer/finished_processing.text.erb new file mode 100644 index 0000000..8f239fa --- /dev/null +++ b/app/views/decidim/direct_verifications/import_mailer/finished_processing.text.erb @@ -0,0 +1,7 @@ +<%= t( + @i18n_key, + count: @stats.count, + successful: @stats.successful, + errors: @stats.errors, + handler: @handler +) %> diff --git a/app/views/decidim/direct_verifications/verification/admin/direct_verifications/index.html.erb b/app/views/decidim/direct_verifications/verification/admin/direct_verifications/index.html.erb index 79d9dbb..7154c54 100644 --- a/app/views/decidim/direct_verifications/verification/admin/direct_verifications/index.html.erb +++ b/app/views/decidim/direct_verifications/verification/admin/direct_verifications/index.html.erb @@ -7,7 +7,7 @@
-

<%= t("decidim.direct_verifications.verification.admin.new.info") %>

+

<%= t("decidim.direct_verifications.verification.admin.new.info_html", link: new_import_path) %>

<%= form_tag direct_verifications_path, multipart: true, class: "form" do %> <%= label_tag :userslist, t("admin.new.textarea", scope: "decidim.direct_verifications.verification") %> <%= text_area_tag :userslist, @userslist, rows: 10 %> diff --git a/app/views/decidim/direct_verifications/verification/admin/imports/new.html.erb b/app/views/decidim/direct_verifications/verification/admin/imports/new.html.erb new file mode 100644 index 0000000..60964d8 --- /dev/null +++ b/app/views/decidim/direct_verifications/verification/admin/imports/new.html.erb @@ -0,0 +1,50 @@ +
+
+

+ <%= t("admin.index.title", scope: "decidim.direct_verifications.verification") %> + <%= link_to t("admin.index.stats", scope: "decidim.direct_verifications.verification"), stats_path, class: "button tiny button--title" %> + <%= link_to t("admin.index.authorizations", scope: "decidim.direct_verifications.verification"), authorizations_path, class: "button tiny button--title" %> +

+
+
+

<%= t("decidim.direct_verifications.verification.admin.imports.new.info") %>

+
+      email, name, membership_phone, membership_type, membership_weight
+      ava@example.com, Ava Hawkins, +3476318371, consumer, 1
+      ewan@example.com, Ewan Cooper, +34653565765, worker, 1
+      tilly@example.com, Tilly Jones, +34653565761, collaborator, 0.67
+      ...
+    
+ + <%= decidim_form_for(@form, url: imports_path, html: { class: "form" }) do |form| %> + + + + + + <%= label_tag :authorization_handler, t("admin.new.authorization_handler", scope: "decidim.direct_verifications.verification") %> + <%= select_tag :authorization_handler, options_for_select(workflows, current_authorization_handler) %> + + <%= form.file_field :file, label: t(".file") %> + <%= form.submit t(".submit"), class: "button" %> + <% end %> + +
+
+ +<%= javascript_include_tag "decidim/direct_verifications/verification/admin/direct_verifications_admin" %> diff --git a/config/initializers/mail_previews.rb b/config/initializers/mail_previews.rb new file mode 100644 index 0000000..1b218c2 --- /dev/null +++ b/config/initializers/mail_previews.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.action_mailer.preview_path = Decidim::DirectVerifications::Verification::Engine.root.join("spec/mailers") +end diff --git a/config/locales/ca.yml b/config/locales/ca.yml index 8bed42c..8bf0b1f 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -32,6 +32,7 @@ ca: create: authorized: "S'han verificat correctament %{authorized} usuaris utilitzant [%{handler}] (%{count} detectats, %{errors} errors)" info: "S'han detectat %{count} usuaris, dels quals %{registered} estan registrats, %{authorized} autoritzats utilitzant [%{handler}] (%{unconfirmed} sense confirmar)" + missing_header: Si us plau, proporcioneu una fila d'encapçalament registered: "S'han registrat correctament %{registered} usuaris (%{count} detectats, %{errors} errors)" revoked: S'ha revocat correctament la verificació de %{revoked} usuaris utilitzant [%{handler}] (%{count} detectats, %{errors} errors) gdpr_disclaimer: Feu-ho sota la vostra responsabilitat. Recordeu que heu de tenir el consentiment explícit dels vostres usuaris per registrar-los. En cas contrari, estareu infringint la regulació GDPR als països de la UE. diff --git a/config/locales/en.yml b/config/locales/en.yml index 695f652..56328e3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -21,6 +21,24 @@ en: direct_verifications: verification: admin: + imports: + new: + info: Import a CSV file with a user entry per line copying the format from the example below + submit: Upload file + file: CSV file with users data + create: + success: File successfully uploaded. We'll email you when all users are imported. + error: There was an error importing the file + mailer: + subject: File import results + authorized: "%{successful} users have been successfully verified using + [%{handler}] (%{count} detected, %{errors} errors)" + info: "%{count} users detected, of which %{registered} are registered, + %{authorized} authorized using [%{handler}] (%{unconfirmed} unconfirmed)" + registered: "%{successful} users have been successfully registered (%{count} + detected, %{errors} errors) " + revoked: Verification from %{successful} users have been revoked using + [%{handler}] (%{count} detected, %{errors} errors) authorizations: index: created_at: Created at @@ -35,6 +53,7 @@ en: [%{handler}] (%{count} detected, %{errors} errors)" info: "%{count} users detected, of which %{registered} are registered, %{authorized} authorized using [%{handler}] (%{unconfirmed} unconfirmed)" + missing_header: Please, provide a header row registered: "%{registered} users have been successfully registered (%{count} detected, %{errors} errors) " revoked: Verification from %{revoked} users have been revoked using @@ -50,8 +69,7 @@ en: authorization_handler: Verification method authorize: Authorize users check: Check users status - info: Enter the emails here, one per line. If the emails are preceded - by a text, it will be interpreted as the user's name + info_html: You can import a CSV or enter the emails here, one per line. If the emails are preceded by a text, it will be interpreted as the user's name. register: Register users in the platform (if they exist they will be ignored) revoke: Revoke authorization from users submit: Send and process the list diff --git a/config/locales/es.yml b/config/locales/es.yml index ab7086d..92bc943 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -32,6 +32,7 @@ es: create: authorized: "Se han verificado correctamente %{authorized} usuarios usando [%{handler}] (%{count} detectados, %{errors} errores)" info: "Se han detectado %{count} usuarios, de los cuales %{registered} están registrados, %{authorized} autorizados usando [%{handler}] (%{unconfirmed} sin confirmar)" + missing_header: Por favor, proporcionad una fila de encabezamiento registered: "Se han registrado correctamente %{registered} usuarios (%{count} detectados, %{errors} errores)" revoked: Se ha revocado correctament la verificación de %{revoked} usuarios usando [%{handler}] (%{count} detectados, %{errors} errores) gdpr_disclaimer: Haga esto bajo su responsabilidad. Recuerde que debe contar con el consentimiento explícito de sus usuarios para poder registrarlos. De lo contrario, estará infringiendo la regulación GDPR en los países de la UE. diff --git a/lib/decidim/direct_verifications/authorize_user.rb b/lib/decidim/direct_verifications/authorize_user.rb index 0b430a9..f6e4124 100644 --- a/lib/decidim/direct_verifications/authorize_user.rb +++ b/lib/decidim/direct_verifications/authorize_user.rb @@ -3,13 +3,16 @@ module Decidim module DirectVerifications class AuthorizeUser - def initialize(email, data, session, organization, instrumenter) + # rubocop:disable Metrics/ParameterLists + def initialize(email, data, session, organization, instrumenter, authorization_handler) @email = email @data = data @session = session @organization = organization @instrumenter = instrumenter + @authorization_handler = authorization_handler end + # rubocop:enable Metrics/ParameterLists def call unless user @@ -31,7 +34,7 @@ def call private - attr_reader :email, :data, :session, :organization, :instrumenter + attr_reader :email, :data, :session, :organization, :instrumenter, :authorization_handler def valid_authorization? !authorization.granted? || authorization.expired? @@ -46,7 +49,7 @@ def authorization begin auth = Authorization.find_or_initialize_by( user: user, - name: :direct_verifications + name: authorization_handler ) auth.metadata = data auth diff --git a/lib/decidim/direct_verifications/instrumenter.rb b/lib/decidim/direct_verifications/instrumenter.rb new file mode 100644 index 0000000..2b8c0e5 --- /dev/null +++ b/lib/decidim/direct_verifications/instrumenter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class Instrumenter + def initialize(current_user) + @current_user = current_user + @errors = { registered: Set.new, authorized: Set.new, revoked: Set.new } + @processed = { registered: Set.new, authorized: Set.new, revoked: Set.new } + end + + def add_processed(type, email) + @processed[type] << email + end + + def add_error(type, email) + @errors[type] << email + end + + def processed_count(key) + processed[key].size + end + + def errors_count(key) + errors[key].size + end + + def emails_count(key) + @processed[key].size + @errors[key].size + end + + def track(event, email, user = nil) + if user + add_processed event, email + log_action user + else + add_error event, email + end + end + + private + + attr_reader :current_user, :processed, :errors + + def log_action(user) + Decidim.traceability.perform_action!( + "invite", + user, + current_user, + extra: { + invited_user_role: "participant", + invited_user_id: user.id + } + ) + end + end + end +end diff --git a/lib/decidim/direct_verifications/register_user.rb b/lib/decidim/direct_verifications/register_user.rb index 018283e..1bd218a 100644 --- a/lib/decidim/direct_verifications/register_user.rb +++ b/lib/decidim/direct_verifications/register_user.rb @@ -3,20 +3,20 @@ module Decidim module DirectVerifications class RegisterUser - def initialize(email, name, organization, current_user, instrumenter) + def initialize(email, data, organization, current_user, instrumenter) @email = email - @name = name + @name = data.is_a?(Hash) ? data[:name] : data @organization = organization @current_user = current_user @instrumenter = instrumenter end def call - return if find_user + return if user InviteUser.call(form) do on(:ok) do - instrumenter.track(:registered, email, find_user) + instrumenter.track(:registered, email, user) end on(:invalid) do instrumenter.track(:registered, email) @@ -31,8 +31,8 @@ def call attr_reader :email, :name, :organization, :current_user, :instrumenter - def find_user - User.find_by(email: email, decidim_organization_id: organization.id) + def user + @user ||= User.find_by(email: email, decidim_organization_id: organization.id) end def form diff --git a/lib/decidim/direct_verifications/revoke_user.rb b/lib/decidim/direct_verifications/revoke_user.rb new file mode 100644 index 0000000..aaabc8c --- /dev/null +++ b/lib/decidim/direct_verifications/revoke_user.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class RevokeUser + def initialize(email, organization, instrumenter, authorization_handler) + @email = email + @organization = organization + @instrumenter = instrumenter + @authorization_handler = authorization_handler + end + + def call + unless user + instrumenter.add_error :revoked, email + return + end + + return unless valid_authorization? + + Verification::DestroyUserAuthorization.call(authorization) do + on(:ok) do + instrumenter.add_processed :revoked, email + end + end + end + + private + + attr_reader :email, :organization, :instrumenter, :authorization_handler + + def user + @user ||= User.find_by(email: email, decidim_organization_id: organization.id) + end + + def authorization + @authorization ||= Authorization.find_by(user: user, name: authorization_handler) + end + + def valid_authorization? + authorization&.granted? + end + end + end +end diff --git a/lib/decidim/direct_verifications/user_processor.rb b/lib/decidim/direct_verifications/user_processor.rb index bf34dc6..e8c528b 100644 --- a/lib/decidim/direct_verifications/user_processor.rb +++ b/lib/decidim/direct_verifications/user_processor.rb @@ -2,18 +2,20 @@ require "decidim/direct_verifications/register_user" require "decidim/direct_verifications/authorize_user" +require "decidim/direct_verifications/revoke_user" +require "decidim/direct_verifications/instrumenter" module Decidim module DirectVerifications class UserProcessor - def initialize(organization, current_user, session) + def initialize(organization, current_user, session, instrumenter) @organization = organization @current_user = current_user @authorization_handler = :direct_verifications - @errors = { registered: [], authorized: [], revoked: [] } - @processed = { registered: [], authorized: [], revoked: [] } + @emails = {} @session = session + @instrumenter = instrumenter end attr_reader :organization, :current_user, :session, :errors, :processed @@ -21,82 +23,25 @@ def initialize(organization, current_user, session) def register_users emails.each do |email, data| - name = if data.is_a?(Hash) - data[:name] - else - data - end - RegisterUser.new(email, name, organization, current_user, self).call + RegisterUser.new(email, data, organization, current_user, instrumenter).call end end def authorize_users emails.each do |email, data| - AuthorizeUser.new(email, data, session, organization, self).call + AuthorizeUser.new(email, data, session, organization, instrumenter, authorization_handler).call end end def revoke_users emails.each do |email, _name| - if (u = find_user(email)) - auth = authorization(u) - next unless auth.granted? - - Verification::DestroyUserAuthorization.call(auth) do - on(:ok) do - add_processed :revoked, email - end - on(:invalid) do - add_error :revoked, email - end - end - else - add_error :revoked, email - end + RevokeUser.new(email, organization, instrumenter, authorization_handler).call end end - def track(event, email, user = nil) - if user - add_processed event, email - log_action user - else - add_error event, email - end - end - - def add_error(type, email) - @errors[type] << email unless @errors[type].include? email - end - - def add_processed(type, email) - @processed[type] << email unless @processed[type].include? email - end - private - def log_action(user) - Decidim.traceability.perform_action!( - "invite", - user, - current_user, - extra: { - invited_user_role: "participant", - invited_user_id: user.id - } - ) - end - - def find_user(email) - User.find_by(email: email, decidim_organization_id: @organization.id) - end - - def authorization(user) - Authorization.find_or_initialize_by( - user: user, - name: authorization_handler - ) - end + attr_reader :instrumenter end end end diff --git a/lib/decidim/direct_verifications/verification/admin_engine.rb b/lib/decidim/direct_verifications/verification/admin_engine.rb index d51c9a5..e6159c4 100644 --- a/lib/decidim/direct_verifications/verification/admin_engine.rb +++ b/lib/decidim/direct_verifications/verification/admin_engine.rb @@ -11,6 +11,7 @@ class AdminEngine < ::Rails::Engine resources :direct_verifications, only: [:index, :create, :stats] resources :stats, only: [:index] resources :authorizations, only: [:index, :destroy] + resources :imports, only: [:new, :create] root to: "direct_verifications#index" end diff --git a/spec/commands/decidim/direct_verifications/verification/create_import_spec.rb b/spec/commands/decidim/direct_verifications/verification/create_import_spec.rb new file mode 100644 index 0000000..44f36e2 --- /dev/null +++ b/spec/commands/decidim/direct_verifications/verification/create_import_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + module Verification + describe CreateImport do + subject(:command) { described_class.new(form) } + + let(:form) do + instance_double( + CreateImportForm, + file: file, + organization: organization, + user: user, + action: action, + authorization_handler: "direct_verifications" + ) + end + let(:filename) { file_fixture("users.csv") } + let(:file) { Rack::Test::UploadedFile.new(filename, "text/csv") } + let(:organization) { build(:organization) } + let(:user) { build(:user) } + let(:action) { :register } + + before do + allow(RegisterUsersJob).to receive(:perform_later) + allow(RevokeUsersJob).to receive(:perform_later) + end + + context "when the form is valid" do + before do + allow(form).to receive(:valid?).and_return(true) + end + + context "when the action is register" do + let(:action) { :register } + + it "calls the RegisterUsersJob job" do + command.call + expect(RegisterUsersJob).to have_received(:perform_later) + end + + it "does not call the RevokeUsersJob job" do + command.call + expect(RevokeUsersJob).not_to have_received(:perform_later) + end + + it "broadcasts ok" do + expect { command.call }.to broadcast(:ok) + end + end + + context "when the action is revoke" do + let(:action) { :revoke } + + it "does not call the RegisterUsersJob job" do + command.call + expect(RegisterUsersJob).not_to have_received(:perform_later) + end + + it "calls the RevokeUsersJob job" do + command.call + expect(RevokeUsersJob).to have_received(:perform_later) + end + + it "broadcasts ok" do + expect { command.call }.to broadcast(:ok) + end + end + end + + context "when the form is not valid" do + before do + allow(form).to receive(:valid?).and_return(false) + end + + it "does not call the RegisterUsersJob job" do + command.call + expect(RegisterUsersJob).not_to have_received(:perform_later) + end + + it "broadcasts ok" do + expect { command.call }.to broadcast(:invalid) + end + end + end + end + end +end diff --git a/spec/commands/decidim/direct_verifications/verification/metadata_parser_spec.rb b/spec/commands/decidim/direct_verifications/verification/metadata_parser_spec.rb index ac154ea..d987966 100644 --- a/spec/commands/decidim/direct_verifications/verification/metadata_parser_spec.rb +++ b/spec/commands/decidim/direct_verifications/verification/metadata_parser_spec.rb @@ -6,6 +6,16 @@ module Decidim::DirectVerifications::Verification describe MetadataParser do subject { described_class.new(txt) } + describe "#header" do + context "when the first line has empty columns" do + let(:txt) { "Melina.morrison@bccm.coop,MORRISON Melina,,,11" } + + it "raises an error" do + expect { subject.to_h }.to raise_error(MissingHeaderError) + end + end + end + describe "#to_h" do context "when the text is empty" do let(:txt) { "" } @@ -29,6 +39,21 @@ module Decidim::DirectVerifications::Verification end end + context "when the text has empty columns" do + let(:txt) do + <<-EMAILS.strip_heredoc + Name,Email,Department,Salary + Bob,bob@example.com,,1000 + EMAILS + end + + it "skips those columns" do + expect(subject.to_h).to eq( + "bob@example.com" => { department: nil, name: "Bob", salary: "1000" } + ) + end + end + context "when the text is a CSV with headers" do let(:txt) do <<-ROWS.strip_heredoc @@ -68,6 +93,21 @@ module Decidim::DirectVerifications::Verification end end + context "and the CSV includes trailing or leading whitespaces" do + let(:txt) do + <<-CSV.strip_heredoc + Name, Email, Type + Ava Hawkins, ava@example.com, collaborator + CSV + end + + it "returns the data in a hash with the email as key" do + expect(subject.to_h).to eq( + "ava@example.com" => { name: "Ava Hawkins", type: "collaborator" } + ) + end + end + context "when the name is not specified" do let(:txt) do <<-EMAILS.strip_heredoc diff --git a/spec/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller_spec.rb b/spec/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller_spec.rb index c4b4fdd..7ce1f70 100644 --- a/spec/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller_spec.rb +++ b/spec/controllers/decidim/direct_verifications/verification/admin/direct_verifications_controller_spec.rb @@ -14,6 +14,8 @@ module Decidim::DirectVerifications::Verification::Admin let(:verification_type) { "direct_verifications" } let(:authorized_user) { create(:user, email: "authorized@example.com", organization: organization) } + let(:i18n_scope) { "decidim.direct_verifications.verification.admin.direct_verifications" } + before do request.env["decidim.current_organization"] = user.organization sign_in user, scope: :user @@ -32,10 +34,13 @@ module Decidim::DirectVerifications::Verification::Admin describe "POST create" do context "when parameters are defaults" do params = { userslist: "" } + it_behaves_like "checking users", params + it "have no registered or authorized users" do perform_enqueued_jobs do post :create, params: params + expect(flash[:info]).to include("0 are registered") expect(flash[:info]).to include("0 authorized") expect(flash[:info]).to include("unconfirmed") @@ -62,7 +67,9 @@ module Decidim::DirectVerifications::Verification::Admin it "renders the index with warning message" do perform_enqueued_jobs do post :create, params: params - expect(subject).to render_template("decidim/direct_verifications/verification/admin/direct_verifications/index") + expect(subject).to render_template( + "decidim/direct_verifications/verification/admin/direct_verifications/index" + ) end end end @@ -81,12 +88,10 @@ module Decidim::DirectVerifications::Verification::Admin end context "when the name is not specified" do + let(:data) { "Name,Email,Type\r\n\"\",brandy@example.com,consumer" } + it "infers the name from the email" do - post :create, params: { - userslist: "Name,Email,Type\r\n\"\",brandy@example.com,consumer", - register: true, - authorize: "in" - } + post :create, params: { userslist: data, register: true, authorize: "in" } user = Decidim::User.find_by(email: "brandy@example.com") expect(user.name).to eq("brandy") @@ -94,6 +99,10 @@ module Decidim::DirectVerifications::Verification::Admin end context "when in metadata mode" do + let(:data) do + "Name,Email,Type\r\nBrandy,brandy@example.com,consumer,2\r\nWhisky,whisky@example.com,producer,3" + end + around do |example| original_processor = Rails.configuration.direct_verifications_parser Rails.configuration.direct_verifications_parser = :metadata @@ -102,36 +111,69 @@ module Decidim::DirectVerifications::Verification::Admin end it "stores any extra columns as authorization metadata" do - post :create, params: { - userslist: "Name,Email,Type\r\nBrandy,brandy@example.com,consumer,2\r\nWhisky,whisky@example.com,producer,3", - register: true, - authorize: "in" - } + post :create, params: { userslist: data, register: true, authorize: "in" } user = Decidim::User.find_by(email: "brandy@example.com") authorization = Decidim::Authorization.find_by(decidim_user_id: user.id) expect(authorization.metadata).to eq("name" => "Brandy", "type" => "consumer") end + context "when a column is empty" do + let(:data) { "Name,Email,Type,City\r\nBrandy,brandy@example.com,,Barcelona" } + + it "sets it nil" do + post :create, params: { userslist: data, register: true, authorize: "in" } + + user = Decidim::User.find_by(email: "brandy@example.com") + authorization = Decidim::Authorization.find_by(decidim_user_id: user.id) + + expect(authorization.metadata) + .to eq("name" => "Brandy", "type" => nil, "city" => "Barcelona") + end + end + context "when the name is not specified" do + let(:data) { "Name,Email,Type\r\n\"\",brandy@example.com,consumer" } + it "infers the name from the email" do - post :create, params: { - userslist: "Name,Email,Type\r\n\"\",brandy@example.com,consumer", - register: true, - authorize: "in" - } + post :create, params: { userslist: data, register: true, authorize: "in" } user = Decidim::User.find_by(email: "brandy@example.com") expect(user.name).to eq("brandy") end end + + context "when the first row has empty columns" do + let(:data) { "brandy@example.com,,consumer" } + + it "shows a user error" do + post :create, params: { userslist: data, register: true, authorize: "in" } + expect(flash[:error]).to eq(I18n.t("#{i18n_scope}.create.missing_header")) + end + end + + context "when no header is provided" do + let(:data) { "brandy@example.com,consumer" } + + it "works" do + post :create, params: { userslist: data, register: true, authorize: "in" } + expect(response).to redirect_to(direct_verifications_path) + end + end end end - context "when register users with revoke" do + context "when revoking users" do params = { userslist: "authorized@example.com", authorize: "out" } it_behaves_like "revoking users", params end + + context "when revoking users with another verification" do + params = { userslist: "authorized@example.com", authorize: "out", authorization_handler: "other" } + let(:verification_type) { "other" } + + it_behaves_like "revoking users", params + end end end end diff --git a/spec/controllers/decidim/direct_verifications/verification/admin/imports_controller_spec.rb b/spec/controllers/decidim/direct_verifications/verification/admin/imports_controller_spec.rb new file mode 100644 index 0000000..e745457 --- /dev/null +++ b/spec/controllers/decidim/direct_verifications/verification/admin/imports_controller_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::DirectVerifications::Verification::Admin + describe ImportsController, type: :controller do + routes { Decidim::DirectVerifications::Verification::AdminEngine.routes } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization: organization) } + + before do + request.env["decidim.current_organization"] = organization + sign_in user + end + + describe "#new" do + it "authorizes the action" do + expect(controller).to receive(:allowed_to?).with(:create, :authorization, {}) + get :new + end + + it "renders the decidim/admin/users layout" do + get :new + expect(response).to render_template("layouts/decidim/admin/users") + end + end + + describe "#create" do + let(:filename) { file_fixture("users.csv") } + let(:file) { Rack::Test::UploadedFile.new(filename, "text/csv") } + + context "when the import is valid" do + it "authorizes the action" do + expect(controller).to receive(:allowed_to?).with(:create, :authorization, {}) + post :create, params: { file: file } + end + + it "redirects to :new" do + post :create, params: { file: file } + expect(response).to redirect_to(new_import_path) + end + end + + context "when the import is not valid" do + it "authorizes the action" do + expect(controller).to receive(:allowed_to?).with(:create, :authorization, {}) + post :create, params: {} + end + + it "redirects to :new" do + post :create, params: {} + expect(response).to redirect_to(new_import_path) + end + + it "displays an error" do + post :create, params: {} + expect(flash[:alert]).to eq(I18n.t("decidim.direct_verifications.verification.admin.imports.create.error")) + end + end + end + end +end diff --git a/spec/fixtures/files/users.csv b/spec/fixtures/files/users.csv new file mode 100644 index 0000000..32ff5ec --- /dev/null +++ b/spec/fixtures/files/users.csv @@ -0,0 +1,2 @@ +Name, Email, Type +Brandy, brandy@example.com, consumer diff --git a/spec/forms/decidim/direct_verifications/verification/create_import_form_spec.rb b/spec/forms/decidim/direct_verifications/verification/create_import_form_spec.rb new file mode 100644 index 0000000..b4e8c4a --- /dev/null +++ b/spec/forms/decidim/direct_verifications/verification/create_import_form_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + module Verification + describe CreateImportForm do + subject(:form) { described_class.from_params(attributes) } + + let(:organization) { build(:organization, available_authorizations: %w(direct_verifications)) } + let(:attributes) do + { + user: build(:user), + organization: organization, + file: double(File), + authorize: action, + authorization_handler: "direct_verifications" + } + end + let(:action) { "in" } + + context "when all attributes are provided" do + it { is_expected.to be_valid } + end + + context "when :register is provided" do + let(:attributes) do + { + user: build(:user), + organization: build(:organization), + file: double(File), + authorize: action, + register: "1" + } + end + + context "when action is in" do + let(:action) { "in" } + + it "returns it as :register_and_authorize" do + expect(form.action).to eq(:register_and_authorize) + end + end + + context "when action is out" do + let(:action) { "out" } + + it "returns it as :register" do + expect(form.action).to eq(:register) + end + end + + context "when action is check" do + let(:action) { "check" } + + it "returns it as :register" do + expect(form.action).to eq(:register) + end + end + + context "when action is unknown" do + let(:action) { "dummy" } + + it { is_expected.not_to be_valid } + end + end + + context "when :register is not provided" do + let(:attributes) do + { + user: build(:user), + organization: build(:organization), + file: double(File), + authorize: action + } + end + + context "when action is in" do + let(:action) { "in" } + + it "returns it as :authorize" do + expect(form.action).to eq(:authorize) + end + end + + context "when action is out" do + let(:action) { "out" } + + it "returns it as :revoke" do + expect(form.action).to eq(:revoke) + end + end + + context "when action is check" do + let(:action) { "check" } + + it "returns it as :check" do + expect(form.action).to eq(:check) + end + end + + context "when action is unknown" do + let(:action) { "dummy" } + + it { is_expected.not_to be_valid } + end + end + + context "when authorization handler is not provided" do + let(:attributes) do + { + user: build(:user), + organization: build(:organization), + file: double(File), + authorize: action + } + end + + it { is_expected.not_to be_valid } + end + + context "when authorization handler is not known" do + let(:attributes) do + { + user: build(:user), + organization: build(:organization), + file: double(File), + authorize: action, + authorization_handler: "unknown" + } + end + + it { is_expected.not_to be_valid } + end + end + end + end +end diff --git a/spec/jobs/decidim/direct_verifications/register_users_job_spec.rb b/spec/jobs/decidim/direct_verifications/register_users_job_spec.rb new file mode 100644 index 0000000..3e59d5b --- /dev/null +++ b/spec/jobs/decidim/direct_verifications/register_users_job_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + describe RegisterUsersJob, type: :job do + let(:userslist) { "Name,Email,Type\r\n\"\",brandy@example.com,consumer" } + let(:organization) { create(:organization) } + let!(:current_user) { create(:user, organization: organization) } + let(:mailer) { double(:mailer, deliver_now: true) } + + around do |example| + perform_enqueued_jobs { example.run } + end + + before do + allow(ImportMailer) + .to receive(:finished_processing) + .with(current_user, kind_of(Instrumenter), :registered, "direct_verifications") + .and_return(mailer) + end + + it "creates the user" do + expect { described_class.perform_later(userslist, organization, current_user, "direct_verifications") } + .to change(Decidim::User, :count).from(1).to(2) + + user = Decidim::User.find_by(email: "brandy@example.com") + expect(user.name).to eq("brandy") + end + + it "does not authorize the user" do + described_class.perform_later(userslist, organization, current_user, "direct_verifications") + + user = Decidim::User.find_by(email: "brandy@example.com") + authorization = Decidim::Authorization.find_by(decidim_user_id: user.id) + expect(authorization).to be_nil + end + + it "notifies the result by email" do + described_class.perform_later(userslist, organization, current_user, "direct_verifications") + expect(mailer).to have_received(:deliver_now) + end + end + end +end diff --git a/spec/jobs/decidim/direct_verifications/revoke_users_job_spec.rb b/spec/jobs/decidim/direct_verifications/revoke_users_job_spec.rb new file mode 100644 index 0000000..8748a60 --- /dev/null +++ b/spec/jobs/decidim/direct_verifications/revoke_users_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + describe RevokeUsersJob, type: :job do + let(:userslist) { "Name,Email,Type\r\n\"\",brandy@example.com,consumer" } + let(:organization) { create(:organization) } + let(:current_user) { create(:user, organization: organization) } + let(:mailer) { double(:mailer, deliver_now: true) } + + let(:user) { create(:user, organization: organization, email: "brandy@example.com") } + let!(:authorization) { create(:authorization, :granted, user: user, name: :direct_verifications) } + + around do |example| + perform_enqueued_jobs { example.run } + end + + before do + allow(ImportMailer) + .to receive(:finished_processing) + .with(current_user, kind_of(Instrumenter), :revoked, "direct_verifications") + .and_return(mailer) + end + + it "deletes the user authorization" do + described_class.perform_later(userslist, organization, current_user, "direct_verifications") + expect(Decidim::Authorization.find_by(id: authorization.id)).to be_nil + end + + it "notifies the result by email" do + described_class.perform_later(userslist, organization, current_user, "direct_verifications") + expect(mailer).to have_received(:deliver_now) + end + end + end +end diff --git a/spec/lib/decidim/direct_verifications/authorize_user_spec.rb b/spec/lib/decidim/direct_verifications/authorize_user_spec.rb index 0534b89..ea66487 100644 --- a/spec/lib/decidim/direct_verifications/authorize_user_spec.rb +++ b/spec/lib/decidim/direct_verifications/authorize_user_spec.rb @@ -5,7 +5,11 @@ module Decidim module DirectVerifications describe AuthorizeUser do - subject { described_class.new(email, data, session, organization, instrumenter) } + subject do + described_class.new(email, data, session, organization, instrumenter, authorization_handler) + end + + let(:authorization_handler) { :direct_verifications } describe "#call" do let(:data) { user.name } @@ -15,7 +19,7 @@ module DirectVerifications let(:user) { create(:user, organization: organization) } let(:email) { user.email } let(:session) { {} } - let(:instrumenter) { instance_double(UserProcessor, add_processed: true, add_error: true) } + let(:instrumenter) { instance_double(Instrumenter, add_processed: true, add_error: true) } context "when passing the user name" do let(:data) { user.name } @@ -108,7 +112,7 @@ module DirectVerifications let(:email) { "em@mail.com" } let(:data) { "Andy" } let(:session) { {} } - let(:instrumenter) { instance_double(UserProcessor, add_processed: true, add_error: true) } + let(:instrumenter) { instance_double(Instrumenter, add_processed: true, add_error: true) } it "tracks an error" do subject.call diff --git a/spec/lib/decidim/direct_verifications/instrumenter_spec.rb b/spec/lib/decidim/direct_verifications/instrumenter_spec.rb new file mode 100644 index 0000000..833841c --- /dev/null +++ b/spec/lib/decidim/direct_verifications/instrumenter_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + describe Instrumenter do + subject { described_class.new(current_user) } + + let(:current_user) { build_stubbed(:user) } + + shared_examples "an error event" do |type| + context "when the email does not exist" do + it "adds the email" do + subject.add_error(type, "email@example.com") + expect(subject.errors_count(type)).to eq(1) + end + end + + context "when the email already exists" do + before { subject.add_error(type, "email@example.com") } + + it "does not duplicate the email" do + subject.add_error(type, "email@example.com") + expect(subject.errors_count(type)).to eq(1) + end + end + end + + shared_examples "a process event" do |type| + context "when the email does not exist" do + it "adds the email" do + subject.add_processed(type, "email@example.com") + expect(subject.processed_count(type)).to eq(1) + end + end + + context "when the email already exists" do + before { subject.add_processed(type, "email@example.com") } + + it "does not duplicate the email" do + subject.add_processed(type, "email@example.com") + expect(subject.processed_count(type)).to eq(1) + end + end + end + + describe "#add_error" do + it_behaves_like "an error event", :registered + it_behaves_like "an error event", :authorized + it_behaves_like "an error event", :revoked + + context "when the provided type does not exist" do + let(:type) { :fake } + + it "raises due to NilClass" do + expect { subject.add_error(type, "email@example.com") }.to raise_error(NoMethodError) + end + end + end + + describe "#add_processed" do + it_behaves_like "an error event", :registered + it_behaves_like "an error event", :authorized + it_behaves_like "an error event", :revoked + + context "when the provided type does not exist" do + let(:type) { :fake } + + it "raises due to NilClass" do + expect { subject.add_processed(type, "email@example.com") }.to raise_error(NoMethodError) + end + end + end + + describe "#track" do + context "when a user is passed" do + let(:user) { build_stubbed(:user) } + + before { allow(Decidim.traceability).to receive(:perform_action!) } + + context "and the provided type exists" do + let(:type) { :registered } + + it "adds the process event" do + subject.track(type, "email@example.com", user) + expect(subject.processed_count(type)).to eq(1) + end + + it "logs the action" do + expect(Decidim.traceability).to receive(:perform_action!).with( + "invite", + user, + current_user, + extra: { + invited_user_role: "participant", + invited_user_id: user.id + } + ) + subject.track(type, "email@example.com", user) + end + end + + context "and the provided type does not exist" do + let(:type) { :fake } + + it "raises due to NilClass" do + expect { subject.track(type, "email@example.com", user) }.to raise_error(NoMethodError) + end + end + end + + context "when a user is not passed" do + context "and the provided type exists" do + let(:type) { :registered } + + it "adds the error event" do + subject.track(type, "email@example.com") + expect(subject.errors_count(type)).to eq(1) + end + end + + context "and the provided type does not exist" do + let(:type) { :fake } + + it "raises due to NilClass" do + expect { subject.track(type, "email@example.com") }.to raise_error(NoMethodError) + end + end + end + end + end + end +end diff --git a/spec/lib/decidim/direct_verifications/register_user_spec.rb b/spec/lib/decidim/direct_verifications/register_user_spec.rb index 9fdcb3b..f1dd803 100644 --- a/spec/lib/decidim/direct_verifications/register_user_spec.rb +++ b/spec/lib/decidim/direct_verifications/register_user_spec.rb @@ -9,7 +9,7 @@ module DirectVerifications let(:user) { build(:user) } let(:organization) { build(:organization) } - let(:instrumenter) { instance_double(UserProcessor, track: true) } + let(:instrumenter) { instance_double(Instrumenter, track: true) } let(:email) { "em@il.com" } let(:name) { "Joni" } diff --git a/spec/lib/decidim/direct_verifications/revoke_user_spec.rb b/spec/lib/decidim/direct_verifications/revoke_user_spec.rb new file mode 100644 index 0000000..d2ec651 --- /dev/null +++ b/spec/lib/decidim/direct_verifications/revoke_user_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + describe RevokeUser do + subject { described_class.new(email, organization, instrumenter, "direct_verifications") } + + let(:user) { create(:user, organization: organization) } + let(:email) { user.email } + + describe "#call" do + let(:organization) { build(:organization) } + let(:instrumenter) { instance_double(Instrumenter, add_processed: true, add_error: true) } + + context "when revoking existing users" do + let(:email) { user.email } + + before { create(:authorization, :granted, user: user, name: :direct_verifications) } + + it "tracks the operation" do + subject.call + expect(instrumenter).to have_received(:add_processed).with(:revoked, email) + end + + it "revokes the user authorization" do + expect(Verification::DestroyUserAuthorization).to receive(:call) + subject.call + end + end + + context "when revoking non-existing users" do + let(:email) { "em@mail.com" } + + it "tracks the error" do + subject.call + expect(instrumenter).to have_received(:add_error).with(:revoked, email) + end + + it "does not revoke the authorization" do + expect(Verification::DestroyUserAuthorization).not_to receive(:call) + subject.call + end + end + + context "when the authorization does not exist" do + it "does not track the operation" do + subject.call + expect(instrumenter).not_to have_received(:add_processed) + expect(instrumenter).not_to have_received(:add_error) + end + + it "does not revoke the authorization" do + expect(Verification::DestroyUserAuthorization).not_to receive(:call) + subject.call + end + end + + context "when the authorization is not granted" do + before { create(:authorization, :pending, user: user, name: :direct_verifications) } + + it "does not track the operation" do + subject.call + expect(instrumenter).not_to have_received(:add_processed) + expect(instrumenter).not_to have_received(:add_error) + end + + it "does not revoke the authorization" do + expect(Verification::DestroyUserAuthorization).not_to receive(:call) + subject.call + end + end + + context "when the authorization fails to be destroyed" do + let(:user) { create(:user, organization: organization) } + let(:email) { user.email } + let(:authorization) { create(:authorization, :granted, user: user, name: :direct_verifications) } + + before do + allow(authorization).to receive(:destroy!).and_raise(ActiveRecord::ActiveRecordError) + allow(Authorization) + .to receive(:find_by) + .with(user: user, name: "direct_verifications") + .and_return(authorization) + end + + it "lets lower-level exceptions pass through" do + expect { subject.call }.to raise_error(ActiveRecord::ActiveRecordError) + end + end + + context "when passing a non-default authorization handler" do + subject { described_class.new(email, organization, instrumenter, authorization_handler) } + + let(:authorization_handler) { :other_verification_method } + let(:user) { create(:user, organization: organization) } + + before { create(:authorization, :granted, user: user, name: authorization_handler) } + + it "tracks the operation" do + subject.call + expect(instrumenter).to have_received(:add_processed).with(:revoked, email) + end + + it "revokes the user authorization" do + expect(Verification::DestroyUserAuthorization).to receive(:call) + subject.call + end + end + end + end + end +end diff --git a/spec/lib/decidim/direct_verifications/user_processor_spec.rb b/spec/lib/decidim/direct_verifications/user_processor_spec.rb index 6133854..d7f2647 100644 --- a/spec/lib/decidim/direct_verifications/user_processor_spec.rb +++ b/spec/lib/decidim/direct_verifications/user_processor_spec.rb @@ -5,13 +5,14 @@ module Decidim module DirectVerifications describe UserProcessor do - subject { described_class.new(organization, user, session) } + subject { described_class.new(organization, user, session, instrumenter) } let(:user) { create(:user, :confirmed, :admin, organization: organization) } let(:session) { double(:session) } let(:organization) do create(:organization, available_authorizations: ["direct_verifications"]) end + let(:instrumenter) { Instrumenter.new(nil) } context "when emails are passed" do it "uses the specified name" do @@ -32,30 +33,6 @@ module DirectVerifications end end - context "when add processed" do - it "has unique emails per type" do - subject.add_processed(:registered, "em@il.com") - subject.add_processed(:registered, "em@il.com") - expect(subject.processed[:registered].count).to eq(1) - - subject.add_processed(:authorized, "em@il.com") - subject.add_processed(:authorized, "em@il.com") - expect(subject.processed[:authorized].count).to eq(1) - end - end - - context "when add errors" do - it "has unique emails per type" do - subject.add_error(:registered, "em@il.com") - subject.add_error(:registered, "em@il.com") - expect(subject.errors[:registered].count).to eq(1) - - subject.add_error(:authorized, "em@il.com") - subject.add_error(:authorized, "em@il.com") - expect(subject.errors[:authorized].count).to eq(1) - end - end - describe "#register_users" do context "when registering valid users" do before do @@ -64,8 +41,8 @@ module DirectVerifications end it "has no errors" do - expect(subject.processed[:registered].count).to eq(2) - expect(subject.errors[:registered].count).to eq(0) + expect(instrumenter.processed_count(:registered)).to eq(2) + expect(instrumenter.errors_count(:registered)).to eq(0) end end @@ -76,8 +53,8 @@ module DirectVerifications end it "has no errors" do - expect(subject.processed[:registered].count).to eq(1) - expect(subject.errors[:registered].count).to eq(0) + expect(instrumenter.processed_count(:registered)).to eq(1) + expect(instrumenter.errors_count(:registered)).to eq(0) end end end @@ -91,8 +68,8 @@ module DirectVerifications it "has no errors" do subject.authorize_users - expect(subject.processed[:authorized].count).to eq(1) - expect(subject.errors[:authorized].count).to eq(0) + expect(instrumenter.processed_count(:authorized)).to eq(1) + expect(instrumenter.errors_count(:authorized)).to eq(0) end end @@ -116,8 +93,8 @@ module DirectVerifications it "has no errors" do subject.authorize_users - expect(subject.processed[:authorized].count).to eq(1) - expect(subject.errors[:authorized].count).to eq(0) + expect(instrumenter.processed_count(:authorized)).to eq(1) + expect(instrumenter.errors_count(:authorized)).to eq(0) end end @@ -140,12 +117,13 @@ module DirectVerifications before do subject.emails = { user.email => user.name } subject.authorize_users - subject.revoke_users end it "has no errors" do - expect(subject.processed[:revoked].count).to eq(1) - expect(subject.errors[:revoked].count).to eq(0) + subject.revoke_users + + expect(instrumenter.processed_count(:revoked)).to eq(1) + expect(instrumenter.errors_count(:revoked)).to eq(0) end end @@ -153,12 +131,13 @@ module DirectVerifications before do subject.emails = ["em@il.com"] subject.authorize_users - subject.revoke_users end - it "has no errors" do - expect(subject.processed[:revoked].count).to eq(0) - expect(subject.errors[:revoked].count).to eq(1) + it "has errors" do + subject.revoke_users + + expect(instrumenter.processed_count(:revoked)).to eq(0) + expect(instrumenter.errors_count(:revoked)).to eq(1) end end end diff --git a/spec/mailers/decidim/direct_verifications/import_mailer_spec.rb b/spec/mailers/decidim/direct_verifications/import_mailer_spec.rb new file mode 100644 index 0000000..dd53fed --- /dev/null +++ b/spec/mailers/decidim/direct_verifications/import_mailer_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module DirectVerifications + describe ImportMailer, type: :mailer do + let(:user) { build(:user) } + + describe "#finished_processing" do + subject(:mail) { described_class.finished_processing(user, instrumenter, type, handler) } + + let(:instrumenter) { instance_double(Instrumenter, emails_count: 1, processed_count: 1, errors_count: 0) } + let(:type) { :registered } + let(:handler) { :direct_verifications } + + it "sends the email to the passed user" do + expect(mail.to).to eq([user.email]) + end + + it "renders the subject" do + expect(mail.subject).to eq(I18n.t("decidim.direct_verifications.verification.admin.imports.mailer.subject")) + end + + it "localizes the subject" do + user.locale = "ca" + expect(I18n).to receive(:with_locale).with("ca") + + mail.body + end + + it "sets the organization" do + expect(mail.body.encoded).to include(user.organization.name) + end + + context "when the type is :registered" do + let(:type) { :registered } + + context "when the import had no errors" do + it "shows the number of errors" do + expect(mail.body.encoded).to include( + "1 users have been successfully registered (1 detected, 0 errors)" + ) + end + end + + context "when the import had errors" do + let(:instrumenter) { instance_double(Instrumenter, emails_count: 1, processed_count: 0, errors_count: 1) } + + it "shows the number of errors" do + expect(mail.body.encoded).to include( + "0 users have been successfully registered (1 detected, 1 errors)" + ) + end + end + end + + context "when the type is :revoked" do + let(:type) { :revoked } + + context "when the import had no errors" do + it "shows the number of errors" do + expect(mail.body.encoded).to include( + "Verification from 1 users have been revoked using [direct_verifications] (1 detected, 0 errors)" + ) + end + end + + context "when the import had errors" do + let(:instrumenter) { instance_double(Instrumenter, emails_count: 1, processed_count: 0, errors_count: 1) } + + it "shows the number of errors" do + expect(mail.body.encoded).to include( + "Verification from 0 users have been revoked using [direct_verifications] (1 detected, 1 errors)" + ) + end + end + end + end + end + end +end diff --git a/spec/mailers/previews/import_mailer_preview.rb b/spec/mailers/previews/import_mailer_preview.rb new file mode 100644 index 0000000..7546c8a --- /dev/null +++ b/spec/mailers/previews/import_mailer_preview.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + module DirectVerifications + class ImportMailerPreview < ActionMailer::Preview + def finished_registration + user = User.first + + instrumenter = Instrumenter.new(user) + instrumenter.add_processed(:registered, "email@example.com") + + ImportMailer.finished_registration(user, instrumenter) + end + end + end +end diff --git a/spec/system/decidim/direct_verifications/admin/admin_imports_users_spec.rb b/spec/system/decidim/direct_verifications/admin/admin_imports_users_spec.rb new file mode 100644 index 0000000..0de5ae9 --- /dev/null +++ b/spec/system/decidim/direct_verifications/admin/admin_imports_users_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin imports users", type: :system do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization: organization) } + + let(:i18n_scope) { "decidim.direct_verifications.verification.admin" } + let(:filename) { file_fixture("users.csv") } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + + organization.available_authorizations = %w(direct_verifications other_verification_method) + organization.save! + Decidim::DirectVerifications.configure do |config| + config.manage_workflows = %w(other_verification_method) + end + + visit decidim_admin_direct_verifications.new_import_path + end + + it "can be accessed from direct_verifications_path" do + visit decidim_admin_direct_verifications.direct_verifications_path + click_link "import a CSV" + expect(page).to have_current_path(decidim_admin_direct_verifications.new_import_path) + end + + context "when registering users" do + it "registers users through a CSV file" do + attach_file("CSV file with users data", filename) + + check(I18n.t("#{i18n_scope}.new.register")) + + perform_enqueued_jobs do + click_button("Upload file") + end + + expect(page).to have_admin_callout(I18n.t("#{i18n_scope}.imports.create.success")) + expect(page).to have_current_path(decidim_admin_direct_verifications.new_import_path) + + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly("brandy@example.com") + expect(ActionMailer::Base.deliveries.first.subject).to eq("Invitation instructions") + + expect(ActionMailer::Base.deliveries.last.body.encoded).to include( + I18n.t("#{i18n_scope}.imports.mailer.registered", count: 1, successful: 1, errors: 0) + ) + end + end + + context "when registering and authorizing users" do + it "registers and authorizes users through a CSV file" do + attach_file("CSV file with users data", filename) + + check(I18n.t("#{i18n_scope}.new.register")) + choose(I18n.t("#{i18n_scope}.new.authorize")) + select( + "translation missing: en.decidim.authorization_handlers.other_verification_method.name", + from: "Verification method" + ) + + perform_enqueued_jobs do + click_button("Upload file") + end + + expect(page).to have_admin_callout(I18n.t("#{i18n_scope}.imports.create.success")) + expect(page).to have_current_path(decidim_admin_direct_verifications.new_import_path) + + click_link I18n.t("index.authorizations", scope: "decidim.direct_verifications.verification.admin") + expect(page).not_to have_content("Brandy") # In the authorizations page we only show those with :direct_verifications + + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly("brandy@example.com") + expect(ActionMailer::Base.deliveries.first.subject).to eq("Invitation instructions") + + expect(ActionMailer::Base.deliveries.second.body.encoded).to include( + I18n.t( + "#{i18n_scope}.imports.mailer.registered", + count: 1, + successful: 1, + errors: 0 + ) + ) + + expect(ActionMailer::Base.deliveries.third.body.encoded).to include( + I18n.t( + "#{i18n_scope}.imports.mailer.authorized", + handler: :other_verification_method, + count: 1, + successful: 1, + errors: 0 + ) + ) + end + end + + context "when revoking users" do + let(:user_to_revoke) do + create(:user, name: "Brandy", email: "brandy@example.com", organization: organization) + end + + before do + create(:authorization, :granted, user: user_to_revoke, name: :other_verification_method) + end + + it "revokes users through a CSV file" do + attach_file("CSV file with users data", filename) + choose(I18n.t("#{i18n_scope}.new.revoke")) + select( + "translation missing: en.decidim.authorization_handlers.other_verification_method.name", + from: "Verification method" + ) + + perform_enqueued_jobs do + click_button("Upload file") + end + + expect(page).to have_admin_callout("successfully") + expect(page).to have_current_path(decidim_admin_direct_verifications.new_import_path) + + click_link I18n.t("index.authorizations", scope: "decidim.direct_verifications.verification.admin") + expect(page).not_to have_content("Brandy") + + expect(ActionMailer::Base.deliveries.last.body.encoded).to include( + I18n.t( + "#{i18n_scope}.imports.mailer.revoked", + handler: :other_verification_method, + count: 1, + successful: 1, + errors: 0 + ) + ) + end + end +end diff --git a/spec/system/decidim/direct_verifications/admin/direct_verifications_spec.rb b/spec/system/decidim/direct_verifications/admin/direct_verifications_spec.rb new file mode 100644 index 0000000..a515e43 --- /dev/null +++ b/spec/system/decidim/direct_verifications/admin/direct_verifications_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin creates direct verifications", type: :system do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization: organization) } + + let(:i18n_scope) { "decidim.direct_verifications.verification.admin.direct_verifications" } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + + visit decidim_admin_direct_verifications.direct_verifications_path + end + + around do |example| + original_processor = Rails.configuration.direct_verifications_parser + Rails.configuration.direct_verifications_parser = :metadata + example.run + Rails.configuration.direct_verifications_parser = original_processor + end + + context "when registering users" do + context "and no header is provided" do + it "shows an error message" do + fill_in "Emails list", with: "brandy@example.com,,consumer" + check "register" + choose "authorize_in" + + click_button "Send and process the list" + + expect(page).to have_content(I18n.t("#{i18n_scope}.create.missing_header")) + end + end + end +end