diff --git a/CHANGELOG.md b/CHANGELOG.md index ca753fa..2f60413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## [Unreleased] +## [0.2.0] - 2024-02-27 + +### Added + +- User entity and your context to handle people business rules. +- Context for new user registration. +- Bcrypt to handle password issues. +- Phonelib to handle phone issues. +- Logger provider with level configuration. +- Configure i18n. +- `pt-BR` translation. +- `en-US` translation. +- Add `pub/sub` pattern and API with dry-events. +- Add `ActiveSupport` to the main application to facilitate general development. +- Configure database cleaner for suite of tests. + +### Fixed + +- Adjusting lint with standardrb throughout the code. +- Lifecyle for core provider. + +### Changed + +- Adjusting directory patterns for test coverage. + ## [0.1.0] - 2024-02-06 - Initial release diff --git a/Gemfile b/Gemfile index ec3061a..ca1daec 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ source "https://rubygems.org" gemspec gem "activesupport", "7.1.3" +gem "bcrypt", "3.1.20" gem "dotenv", "3.0.2" gem "dry-events", "1.0.1" gem "dry-matcher", "1.0.0" @@ -14,6 +15,7 @@ gem "dry-system", "1.0.1" gem "dry-validation", "1.10.0" gem "money", "6.16.0" gem "pg", "1.5.5" +gem "phonelib", "0.8.7" gem "rake", "13.1.0" gem "rom", "5.3.0" gem "rom-sql", "3.6.2" diff --git a/Gemfile.lock b/Gemfile.lock index b55b68b..7bc5b73 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - auction_fun_core (0.1.0) + auction_fun_core (0.2.0) GEM remote: https://rubygems.org/ @@ -18,6 +18,7 @@ GEM tzinfo (~> 2.0) ast (2.4.2) base64 (0.2.0) + bcrypt (3.1.20) bigdecimal (3.1.6) coderay (1.1.3) concurrent-ruby (1.2.3) @@ -104,6 +105,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.5) + phonelib (0.8.7) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) @@ -209,6 +211,7 @@ PLATFORMS DEPENDENCIES activesupport (= 7.1.3) auction_fun_core! + bcrypt (= 3.1.20) database_cleaner-sequel (= 2.0.2) dotenv (= 3.0.2) dry-events (= 1.0.1) @@ -219,6 +222,7 @@ DEPENDENCIES faker (= 3.2.3) money (= 6.16.0) pg (= 1.5.5) + phonelib (= 0.8.7) pry (= 0.14.2) rake (= 13.1.0) rom (= 5.3.0) diff --git a/config/app.rb b/config/app.rb index 6886832..4aea664 100644 --- a/config/app.rb +++ b/config/app.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require_relative 'application' +require_relative "application" AuctionFunCore::Application.finalize! diff --git a/config/application.rb b/config/application.rb index 05cb64a..62e27d2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,30 +1,31 @@ # frozen_string_literal: true -require_relative 'boot' +require_relative "boot" -require 'zeitwerk' -require 'i18n' -require 'dry/system' -require 'dry/system/loader/autoloading' -require 'dry/auto_inject' -require 'dry/types' +require "bcrypt" +require "zeitwerk" +require "i18n" +require "dry/system" +require "dry/system/loader/autoloading" +require "dry/auto_inject" +require "dry/types" module AuctionFunCore # Main class (Add doc) class Application < Dry::System::Container I18n.available_locales = %w[en-US pt-BR] - I18n.default_locale = 'pt-BR' - use :env, inferrer: -> { ENV.fetch('APP_ENV', 'development').to_sym } + I18n.default_locale = "pt-BR" + use :env, inferrer: -> { ENV.fetch("APP_ENV", "development").to_sym } use :zeitwerk, run_setup: true, eager_load: true configure do |config| - config.root = File.expand_path('..', __dir__) + config.root = File.expand_path("..", __dir__) - config.component_dirs.add 'lib' do |dir| + config.component_dirs.add "lib" do |dir| dir.auto_register = true dir.loader = Dry::System::Loader::Autoloading dir.add_to_load_path = true - dir.namespaces.add 'auction_fun_core', key: nil + dir.namespaces.add "auction_fun_core", key: nil end end end diff --git a/config/boot.rb b/config/boot.rb index 5dee28d..2af1743 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -ENV['APP_ENV'] ||= 'development' +ENV["APP_ENV"] ||= "development" -require 'bundler' -Bundler.setup(:default, ENV.fetch('APP_ENV', nil)) +require "bundler" +Bundler.setup(:default, ENV.fetch("APP_ENV", nil)) unless defined?(Dotenv) - require 'dotenv' + require "dotenv" Dotenv.load(".env.#{ENV.fetch("APP_ENV", nil)}") - Dotenv.require_keys('DATABASE_URL') + Dotenv.require_keys("DATABASE_URL") end diff --git a/config/locales/contracts/en-US.yml b/config/locales/contracts/en-US.yml new file mode 100644 index 0000000..c466bbb --- /dev/null +++ b/config/locales/contracts/en-US.yml @@ -0,0 +1,66 @@ +en-US: + contracts: + errors: + or: "or" + array?: "must be an array" + empty?: "must be empty" + excludes?: "must not include %{value}" + excluded_from?: + arg: + default: "must not be one of: %{list}" + range: "must not be one of: %{list_left} - %{list_right}" + exclusion?: "must not be one of: %{list}" + eql?: "must be equal to %{left}" + not_eql?: "must not be equal to %{left}" + filled?: "must be filled" + format?: "is in invalid format" + number?: "must be a number" + odd?: "must be odd" + even?: "must be even" + gt?: "must be greater than %{num}" + gteq?: "must be greater than or equal to %{num}" + hash?: "must be a hash" + included_in?: + arg: + default: "must be one of: %{list}" + range: "must be one of: %{list_left} - %{list_right}" + inclusion?: "must be one of: %{list}" + includes?: "must include %{value}" + bool?: "must be boolean" + true?: "must be true" + false?: "must be false" + int?: "must be an integer" + float?: "must be a float" + decimal?: "must be a decimal" + date?: "must be a date" + date_time?: "must be a date time" + time?: "must be a time" + key?: "is required" + attr?: "is required" + lt?: "must be less than %{num}" + lteq?: "must be less than or equal to %{num}" + max_size?: "size cannot be greater than %{num}" + min_size?: "size cannot be less than %{num}" + none?: "cannot be defined" + str?: "must be a string" + type?: "must be %{type}" + size?: + arg: + default: "size must be %{size}" + range: "size must be within %{size_left} - %{size_right}" + value: + string: + arg: + default: "length must be %{size}" + range: "length must be within %{size_left} - %{size_right}" + custom: + errors: + default: + taken: "has already been taken" + not_found: "not found" + password_confirmation: "doesn't match password" + macro: + email_format: "need to be a valid email" + name_format: "must be between %{min} and %{max} characters" + password_format: "must be between %{min} and %{max} characters" + phone_format: "need to be a valid mobile number" diff --git a/config/locales/contracts/pt-BR.yml b/config/locales/contracts/pt-BR.yml new file mode 100644 index 0000000..1ba8742 --- /dev/null +++ b/config/locales/contracts/pt-BR.yml @@ -0,0 +1,65 @@ +pt-BR: + contracts: + errors: + or: "ou" + array?: "deve ser um array" + empty?: "deve ficar vazio" + excludes?: "não deve incluir %{value}" + excluded_from?: + arg: + default: "não deve ser um de: %{list}" + range: "não deve ser um de: %{list_left} - %{list_right}" + exclusion?: "não deve ser um de: %{list}" + eql?: "deve ser igual a %{left}" + not_eql?: "não deve ser igual a %{left}" + filled?: "deve ser preenchido" + format?: "está em formato inválido" + number?: "deve ser um número" + odd?: "deve ser ímpar" + even?: "deve ser par" + gt?: "deve ser maior que %{num}" + gteq?: "deve ser maior ou igual a %{num}" + hash?: "deve ser do tipo hash" + included_in?: + arg: + default: "deve ser um de: %{list}" + range: "deve ser um de: %{list_left} - %{list_right}" + inclusion?: "deve ser um de: %{list}" + includes?: "deve incluir %{value}" + bool?: "deve ser booleano" + true?: "deve ser verdadeiro" + false?: "deve ser falso" + int?: "deve ser um número inteiro" + float?: "deve ser um número real" + decimal?: "deve ser um número decimal" + date?: "deve ser no formato de data" + date_time?: "deve ser no formato de data e hora" + time?: "deve ser no formato de tempo" + key?: "é requerido" + attr?: "é requerido" + lt?: "deve ser menor que %{num}" + lteq?: "deve ser menor ou igual a %{num}" + max_size?: "tamanho não pode ser maior que %{num}" + min_size?: "tamanho não pode ser menor que %{num}" + none?: "não pode ser definido" + str?: "deve ser do tipo texto" + type?: "deve ser do tipo %{type}" + size?: + arg: + default: "tamanho deve ser %{size}" + range: "tamanho deve ser entre %{size_left} e %{size_right}" + value: + string: + arg: + default: "deve ser %{size} caracteres" + range: "deve ser entre %{size_left} e %{size_right} caracteres" + custom: + default: + taken: "não está disponível" + not_found: "não encontrado" + password_confirmation: "não corresponde à senha" + macro: + email_format: "não é um email válido" + name_format: "deve ter entre %{min} e %{max} caracteres" + password_format: "deve ter entre %{min} e %{max} caracteres" + phone_format: "não é um número de celular válido" diff --git a/db/migrate/20240217115734_create_users.rb b/db/migrate/20240217115734_create_users.rb new file mode 100644 index 0000000..f3541fa --- /dev/null +++ b/db/migrate/20240217115734_create_users.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table(:users) do + primary_key :id + column :name, String, null: false + column :email, String, null: false + column :phone, String, null: false + column :password_digest, String, null: false + column :active, TrueClass, null: false, default: true + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + + index :email, unique: true + index :phone, unique: true + end + end +end diff --git a/lib/auction_fun_core.rb b/lib/auction_fun_core.rb index cc3ddea..2a63167 100644 --- a/lib/auction_fun_core.rb +++ b/lib/auction_fun_core.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true require_relative "auction_fun_core/version" -require 'zeitwerk' +require "zeitwerk" -require_relative '../config/boot' -require_relative '../config/application' +require_relative "../config/boot" +require_relative "../config/application" module AuctionFunCore class Error < StandardError; end def self.root - File.expand_path '..', __dir__ + File.expand_path "..", __dir__ end - autoload :Application, Pathname.new(File.expand_path('../config/application', __dir__)) + autoload :Application, Pathname.new(File.expand_path("../config/application", __dir__)) end diff --git a/lib/auction_fun_core/commands/user_context/create_user.rb b/lib/auction_fun_core/commands/user_context/create_user.rb new file mode 100644 index 0000000..5b42de9 --- /dev/null +++ b/lib/auction_fun_core/commands/user_context/create_user.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Commands + module UserContext + ## + # Abstract base class for insert new tuples on users table. + # @abstract + class CreateUser < ROM::Commands::Create[:sql] + relation :users + register_as :create + result :one + + use :timestamps + timestamp :created_at, :updated_at + end + end + end +end diff --git a/lib/auction_fun_core/contracts/application_contract.rb b/lib/auction_fun_core/contracts/application_contract.rb new file mode 100644 index 0000000..5c8f550 --- /dev/null +++ b/lib/auction_fun_core/contracts/application_contract.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "phonelib" + +module AuctionFunCore + module Contracts + # Abstract base class for contracts. + # @abstract + class ApplicationContract < Dry::Validation::Contract + I18N_MACRO_SCOPE = "contracts.errors.custom.macro" + EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i + MIN_NAME_LENGTH = 6 + MAX_NAME_LENGTH = 128 + MIN_PASSWORD_LENGTH = 6 + MAX_PASSWORD_LENGTH = 128 + + config.messages.backend = :i18n + config.messages.default_locale = I18n.default_locale + config.messages.top_namespace = "contracts" + config.messages.load_paths << Application.root.join("config/locales/contracts/#{I18n.default_locale}.yml").to_s + + register_macro(:email_format) do + next if EMAIL_REGEX.match?(value) + + key.failure(I18n.t(:email_format, scope: I18N_MACRO_SCOPE)) + end + + register_macro(:name_format) do + next if value.length.between?(MIN_NAME_LENGTH, MAX_NAME_LENGTH) + + key.failure( + I18n.t(:name_format, scope: I18N_MACRO_SCOPE, min: MIN_NAME_LENGTH, max: MAX_NAME_LENGTH) + ) + end + + register_macro(:phone_format) do + next if ::Phonelib.parse(value).valid? + + key.failure(I18n.t(:phone_format, scope: I18N_MACRO_SCOPE)) + end + + register_macro(:password_format) do + next if value.length.between?(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH) + + key.failure( + I18n.t(:password_format, scope: I18N_MACRO_SCOPE, min: MIN_PASSWORD_LENGTH, max: MAX_PASSWORD_LENGTH) + ) + end + end + end +end diff --git a/lib/auction_fun_core/contracts/user_context/registration_contract.rb b/lib/auction_fun_core/contracts/user_context/registration_contract.rb new file mode 100644 index 0000000..07b3759 --- /dev/null +++ b/lib/auction_fun_core/contracts/user_context/registration_contract.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module UserContext + # Contract class to create new users. + class RegistrationContract < ApplicationContract + I18N_SCOPE = "contracts.errors.custom.default" + + option :user_repository, default: proc { Repos::UserContext::UserRepository.new } + + # @param [Hash] opts Sets an allowed list of parameters, as well as some initial validations. + params do + required(:name) + required(:email) + required(:phone) + required(:password) + required(:password_confirmation) + + before(:value_coercer) do |result| + result.to_h.compact + end + + # Normalize and add default values + after(:value_coercer) do |result| + result.update(email: result[:email].strip.downcase) if result[:email] + result.update(phone: result[:phone].tr_s("^0-9", "")) if result[:phone] + end + end + + rule(:name).validate(:name_format) + + # Validation for email. + # It must validate the format and uniqueness in the database. + rule(:email).validate(:email_format) + rule(:email) do + # Email should be unique on database + if !rule_error?(:email) && user_repository.exists?(email: value) + key.failure(I18n.t(:taken, scope: I18N_SCOPE)) + end + end + + # Validation for phone. + # It must validate the format and uniqueness in the database. + rule(:phone).validate(:phone_format) + rule(:phone) do + if !rule_error?(:phone) && user_repository.exists?(phone: value) + key.failure(I18n.t(:taken, scope: I18N_SCOPE)) + end + end + + # Validation for password. + # Check if the confirmation matches the password. + rule(:password).validate(:password_format) + rule(:password, :password_confirmation) do + if !rule_error?(:password) && values[:password] != values[:password_confirmation] + key(:password_confirmation).failure(I18n.t(:password_confirmation, scope: I18N_SCOPE)) + end + end + end + end + end +end diff --git a/lib/auction_fun_core/entities/user.rb b/lib/auction_fun_core/entities/user.rb new file mode 100644 index 0000000..3757d47 --- /dev/null +++ b/lib/auction_fun_core/entities/user.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Entities + # User Relations class. This return simple objects with attribute readers + # to represent data in your user. + class User < ROM::Struct + def info + attributes.except(:password_digest) + end + end + end +end diff --git a/lib/auction_fun_core/events/app.rb b/lib/auction_fun_core/events/app.rb new file mode 100644 index 0000000..903c405 --- /dev/null +++ b/lib/auction_fun_core/events/app.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Events + # Event class to register business events on system. + # @see https://dry-rb.org/gems/dry-events/main/ + class App + # @!parser include Dry::Events::Publisher[:app] + include Dry::Events::Publisher[:app] + + register_event("users.registration") + end + end +end diff --git a/lib/auction_fun_core/events/listener.rb b/lib/auction_fun_core/events/listener.rb new file mode 100644 index 0000000..4b3ecfb --- /dev/null +++ b/lib/auction_fun_core/events/listener.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Events + # Event class that can listen business events. + # @see https://dry-rb.org/gems/dry-events/main/#event-listeners + class Listener + # Listener for to *users.registration* event. + # @param event [ROM::Struct::User] the user object + def on_users_registration(user) + logger("New registered user: #{user.to_h}") + end + + private + + # Append message to system log. + # @param message [String] the message + def logger(message) + Application[:logger].info(message) + end + end + end +end diff --git a/lib/auction_fun_core/operations/base.rb b/lib/auction_fun_core/operations/base.rb new file mode 100644 index 0000000..4845b2f --- /dev/null +++ b/lib/auction_fun_core/operations/base.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + # Abstract base class for operations. + # @abstract + class Base + include Dry::Monads[:do, :maybe, :result, :try] + end + end +end diff --git a/lib/auction_fun_core/operations/user_context/registration_operation.rb b/lib/auction_fun_core/operations/user_context/registration_operation.rb new file mode 100644 index 0000000..cc83697 --- /dev/null +++ b/lib/auction_fun_core/operations/user_context/registration_operation.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module UserContext + ## + # Operation class for create new users. + # + class RegistrationOperation < AuctionFunCore::Operations::Base + include Import["contracts.user_context.registration_contract"] + include Import["repos.user_context.user_repository"] + + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + # @todo Add custom doc + def call(attributes) + values = yield validate(attributes) + values_with_encrypt_password = yield encrypt_password(values) + + user_repository.transaction do |_t| + @user = yield persist(values_with_encrypt_password) + + yield publish_user_registration(@user.id) + end + + Success(@user) + end + + # Calls the user creation contract class to perform the validation + # of the informed attributes. + # @param attrs [Hash] user attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate(attrs) + registration_contract.call(attrs).to_monad + end + + # Transforms the password attribute, encrypting it to be saved in the database. + # @param result [Hash] User valid contract attributes + # @return [Hash] Valid user database + def encrypt_password(attrs) + attributes = attrs.to_h.except(:password) + + Success( + {**attributes, password_digest: BCrypt::Password.create(attrs[:password])} + ) + end + + # Calls the user repository class to persist the attributes in the database. + # @param result [Hash] User validated attributes + # @return [ROM::Struct::User] + def persist(result) + Success(user_repository.create(result)) + end + + # Triggers the publication of event *users.registration*. + # @param user_id [Integer] User ID + # @return [Dry::Monads::Result::Success] + def publish_user_registration(user_id) + user = user_repository.by_id!(user_id) + + Success( + Application[:event].publish("users.registration", user.info) + ) + end + end + end + end +end diff --git a/lib/auction_fun_core/relations/users.rb b/lib/auction_fun_core/relations/users.rb new file mode 100644 index 0000000..0426529 --- /dev/null +++ b/lib/auction_fun_core/relations/users.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Relations + # SQL relation for users. + # @see https://rom-rb.org/5.0/learn/sql/relations/ + class Users < ROM::Relation[:sql] + use :pagination, per_page: 10 + + schema(:users, infer: true) do + attribute :id, Types::Integer + attribute :name, Types::String + attribute :email, Types::String + attribute :phone, Types::String + attribute :active, Types::Bool + + primary_key :id + end + + struct_namespace Entities + auto_struct(true) + end + end +end diff --git a/lib/auction_fun_core/repos/user_context/user_repository.rb b/lib/auction_fun_core/repos/user_context/user_repository.rb new file mode 100644 index 0000000..98aca69 --- /dev/null +++ b/lib/auction_fun_core/repos/user_context/user_repository.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Repos + module UserContext + # SQL repository for users. + class UserRepository < ROM::Repository[:users] + include Import["container"] + + struct_namespace Entities + commands :create, update: :by_pk, delete: :by_pk + + # Returns all users in database. + # @return [Array, []] + def all + users.to_a + end + + # Returns the total number of users in database. + # @return [Integer] + def count + users.count + end + + # Mount SQL conditions in query for search in database. + # @param conditions [Hash] DSL Dataset + # @return [AuctionCore::Relations::Users] + def query(conditions) + users.where(conditions) + end + + # Search user in database by primary key. + # @param id [Integer] User ID + # @return [ROM::Struct::User, nil] + def by_id(id) + users.by_pk(id).one + end + + # Search user in database by primary key. + # @param id [Integer] User ID + # @raise [ROM::TupleCountMismatchError] if not found on database + # @return [ROM::Struct::Auction] + def by_id!(id) + users.by_pk(id).one! + end + + # Checks if it returns any user given one or more conditions. + # @param conditions [Hash] DSL Dataset + # @return [true] when some user is returned from the given condition. + # @return [false] when no user is returned from the given condition. + def exists?(conditions) + users.exist?(conditions) + end + end + end + end +end diff --git a/lib/auction_fun_core/version.rb b/lib/auction_fun_core/version.rb index e34875d..472ccc3 100644 --- a/lib/auction_fun_core/version.rb +++ b/lib/auction_fun_core/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AuctionFunCore - VERSION = "0.1.0" + VERSION = "0.2.0" # Required class module is a gem dependency class Version; end diff --git a/spec/auction_fun_core/contracts/user_context/registration_contract_spec.rb b/spec/auction_fun_core/contracts/user_context/registration_contract_spec.rb new file mode 100644 index 0000000..84a17d3 --- /dev/null +++ b/spec/auction_fun_core/contracts/user_context/registration_contract_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Contracts::UserContext::RegistrationContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when params are blank" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:name]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:email]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:phone]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:password]).to include(I18n.t("contracts.errors.key?")) + end + end + + it_behaves_like "validate_name_contract", :user + it_behaves_like "validate_email_contract", :user + it_behaves_like "validate_phone_contract", :user + it_behaves_like "validate_password_contract", :user + it_behaves_like "validate_password_confirmation_contract", :user + end +end diff --git a/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb b/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb new file mode 100644 index 0000000..e5b6e88 --- /dev/null +++ b/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::UserContext::RegistrationOperation, type: :operation do + let(:user_repository) { AuctionFunCore::Repos::UserContext::UserRepository.new } + + describe ".call(attributes, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:attributes) do + Factory.structs[:user] + .to_h + .except(:id, :created_at, :updated_at, :password_digest) + .merge!(password: "password", password_confirmation: "password") + end + + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_a(AuctionFunCore::Entities::User) + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure.errors.to_h[:name]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract are not valid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect not persist new user on database" do + expect(user_repository.count).to be_zero + + expect { operation }.not_to change(user_repository, :count) + end + + it "expect return failure with error messages" do + expect(operation).to be_failure + expect(operation.failure.errors).to be_present + end + end + + context "when contract are valid" do + let(:attributes) do + Factory.structs[:user] + .to_h + .except(:id, :created_at, :updated_at, :password_digest) + .merge!(password: "password", password_confirmation: "password") + end + + before do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + end + + it "expect persist new user on database and dispatch event registration" do + expect { operation }.to change(user_repository, :count).from(0).to(1) + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + + it "expect return success without error messages" do + expect(operation).to be_success + expect(operation.failure).to be_blank + end + end + end +end diff --git a/spec/auction_fun_core/repos/user_context/user_repository_spec.rb b/spec/auction_fun_core/repos/user_context/user_repository_spec.rb new file mode 100644 index 0000000..56b4f86 --- /dev/null +++ b/spec/auction_fun_core/repos/user_context/user_repository_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Repos::UserContext::UserRepository, type: :repo do + subject(:repo) { described_class.new } + + describe "#create" do + let(:attributes) { Factory.structs[:user] } + + let(:user) do + repo.create( + name: attributes.name, + email: attributes.email, + phone: attributes.phone, + password_digest: BCrypt::Password.create("password") + ) + end + + it "expect create a new auction on repository" do + expect(user).to be_a(AuctionFunCore::Entities::User) + expect(user.name).to eq(attributes.name) + expect(user.email).to eq(attributes.email) + expect(user.phone).to eq(attributes.phone) + expect(user.password_digest).to be_present + expect(user.created_at).not_to be_blank + expect(user.updated_at).not_to be_blank + end + end + + describe "#update" do + let(:user) { Factory[:user] } + let(:new_name) { "New name" } + + it "expect update user on repository" do + expect { repo.update(user.id, name: new_name) } + .to change { repo.by_id(user.id).name } + .from(user.name) + .to(new_name) + end + end + + describe "#delete" do + let!(:user) { Factory[:user] } + + it "expect remove user on repository" do + expect { repo.delete(user.id) } + .to change(repo, :count) + .from(1).to(0) + end + end + + describe "#all" do + let!(:user) { Factory[:user] } + + it "expect return all users" do + expect(repo.all.size).to eq(1) + expect(repo.all.first.id).to eq(user.id) + end + end + + describe "#count" do + context "when has not user on repository" do + it "expect return zero" do + expect(repo.count).to be_zero + end + end + + context "when has users on repository" do + let!(:auction) { Factory[:user] } + + it "expect return total" do + expect(repo.count).to eq(1) + end + end + end + + describe "#query(conditions)" do + let(:conditions) { {active: true} } + + it "expect add sql conditions in query" do + expect(repo.query(conditions).dataset.sql).to include('WHERE ("active" IS TRUE)') + end + end + + describe "#by_id(id)" do + context "when id is founded on repository" do + let!(:user) { Factory[:user] } + + it "expect return rom object" do + expect(repo.by_id(user.id)).to be_a(AuctionFunCore::Entities::User) + end + end + + context "when id is not found on repository" do + it "expect return nil" do + expect(repo.by_id(nil)).to be_nil + end + end + end + + describe "#by_id!(id)" do + context "when id is founded on repository" do + let!(:user) { Factory[:user] } + + it "expect return rom object" do + expect(repo.by_id(user.id)).to be_a(AuctionFunCore::Entities::User) + end + end + + context "when id is not found on repository" do + it "expect raise exception" do + expect { repo.by_id!(nil) }.to raise_error(ROM::TupleCountMismatchError) + end + end + end +end diff --git a/spec/auction_fun_core_spec.rb b/spec/auction_fun_core_spec.rb index 8b58d01..25d76a9 100644 --- a/spec/auction_fun_core_spec.rb +++ b/spec/auction_fun_core_spec.rb @@ -2,6 +2,6 @@ RSpec.describe AuctionFunCore do it "has a version number" do - expect(AuctionFunCore::VERSION).not_to be nil + expect(AuctionFunCore::VERSION).to eq("0.2.0") end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9c2bfce..7615df1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,21 +1,27 @@ # frozen_string_literal: true -ENV['APP_ENV'] = 'test' +ENV["APP_ENV"] = "test" -if ENV['CI'] - require 'simplecov' +if ENV["CI"] + require "simplecov" SimpleCov.start do - add_filter '/spec' - add_group 'System', 'system' - add_group 'Config', 'config' + add_filter "/spec" + add_group "Commands", "lib/auction_fun_core/commands" + add_group "Contracts", "lib/auction_fun_core/contracts" + add_group "Entities", "lib/auction_fun_core/entities" + add_group "Operations", "lib/auction_fun_core/operations" + add_group "Relations", "lib/auction_fun_core/relations" + add_group "Repositories", "lib/auction_fun_core/repos" + add_group "System", "system" + add_group "Config", "config" end end -require_relative '../config/application' -require 'pry' -require 'dotenv' -require 'rom-factory' -require 'database_cleaner/sequel' +require_relative "../config/application" +require "pry" +require "dotenv" +require "rom-factory" +require "database_cleaner/sequel" AuctionFunCore::Application.start(:core) @@ -38,4 +44,14 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + config.before do + DatabaseCleaner.clean + end + + config.after(:suite) do + AuctionFunCore::Application.stop(:core) + end end + +DatabaseCleaner.strategy = :truncation diff --git a/spec/support/factories/users.rb b/spec/support/factories/users.rb new file mode 100644 index 0000000..2eda793 --- /dev/null +++ b/spec/support/factories/users.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Factory.define(:user) do |f| + f.name { fake(:name, :name) } + f.email { fake(:internet, :email) } + f.phone { fake(:phone_number, :cell_phone_in_e164).tr_s("^0-9", "") } + f.password_digest { BCrypt::Password.create("password") } + + f.trait :disabled do |t| + t.active { false } + end +end diff --git a/spec/support/shared_examples/validate_email_contract.rb b/spec/support/shared_examples/validate_email_contract.rb new file mode 100644 index 0000000..ac835a6 --- /dev/null +++ b/spec/support/shared_examples/validate_email_contract.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +shared_examples "validate_email_contract" do |factory_name| + let(:factory) { Factory[factory_name] } + + context "when email is in wrong format" do + let(:attributes) { {email: "wrongemail"} } + + it "expect failure with error messages" do + expect(subject).to be_failure + + expect(subject.errors[:email]).to include(I18n.t("contracts.errors.custom.macro.email_format")) + end + end + + context "when email is already exists on database" do + let(:attributes) { {email: factory.email} } + + it "expect failure with error messages" do + expect(subject).to be_failure + + expect(subject.errors[:email]).to include(I18n.t("contracts.errors.custom.default.taken")) + end + end +end diff --git a/spec/support/shared_examples/validate_name_contract.rb b/spec/support/shared_examples/validate_name_contract.rb new file mode 100644 index 0000000..8c6121b --- /dev/null +++ b/spec/support/shared_examples/validate_name_contract.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +shared_examples "validate_name_contract" do |factory_name| + let(:min) { AuctionFunCore::Contracts::ApplicationContract::MIN_NAME_LENGTH } + let(:max) { AuctionFunCore::Contracts::ApplicationContract::MAX_NAME_LENGTH } + let(:factory) { Factory[factory_name] } + + context "when the characters in the name are outside the allowed range" do + let(:attributes) { {name: "abc"} } + + it "expect failure with error messages" do + expect(subject).to be_failure + + expect(subject.errors[:name]).to include( + I18n.t("contracts.errors.custom.macro.name_format", min: min, max: max) + ) + end + end +end diff --git a/spec/support/shared_examples/validate_password_confirmation_contract.rb b/spec/support/shared_examples/validate_password_confirmation_contract.rb new file mode 100644 index 0000000..a5e2827 --- /dev/null +++ b/spec/support/shared_examples/validate_password_confirmation_contract.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +shared_examples "validate_password_confirmation_contract" do |factory_name| + let(:factory) { Factory[factory_name] } + + context "when password characters are outside the allowed range" do + let(:attributes) { {password: "password", password_confirmation: "1234567"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:password_confirmation]).to include( + I18n.t("contracts.errors.custom.default.password_confirmation") + ) + end + end +end diff --git a/spec/support/shared_examples/validate_password_contract.rb b/spec/support/shared_examples/validate_password_contract.rb new file mode 100644 index 0000000..6e838d1 --- /dev/null +++ b/spec/support/shared_examples/validate_password_contract.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +shared_examples "validate_password_contract" do |factory_name| + let(:min) { AuctionFunCore::Contracts::ApplicationContract::MIN_PASSWORD_LENGTH } + let(:max) { AuctionFunCore::Contracts::ApplicationContract::MAX_PASSWORD_LENGTH } + let(:factory) { Factory[factory_name] } + + context "when password characters are outside the allowed range" do + let(:attributes) { {password: "123", password_confirmation: "123"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:password]).to include( + I18n.t("contracts.errors.custom.macro.password_format", min: min, max: max) + ) + end + end +end diff --git a/spec/support/shared_examples/validate_phone_contract.rb b/spec/support/shared_examples/validate_phone_contract.rb new file mode 100644 index 0000000..df80c36 --- /dev/null +++ b/spec/support/shared_examples/validate_phone_contract.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +shared_examples "validate_phone_contract" do |factory_name| + let(:factory) { Factory[factory_name] } + + context "when phone is in wrong format" do + let(:attributes) { {phone: "12345"} } + + it "expect failure with error messages" do + expect(subject).to be_failure + + expect(subject.errors[:phone]).to include(I18n.t("contracts.errors.custom.macro.phone_format")) + end + end + + context "when phone is already exists on database" do + let(:attributes) { {phone: factory.phone} } + + it "expect failure with error messages" do + expect(subject).to be_failure + + expect(subject.errors[:phone]).to include(I18n.t("contracts.errors.custom.default.taken")) + end + end +end diff --git a/system/providers/core.rb b/system/providers/core.rb index bad627a..59f22c5 100644 --- a/system/providers/core.rb +++ b/system/providers/core.rb @@ -3,15 +3,13 @@ # This file load requirements to main API. AuctionFunCore::Application.register_provider(:core) do prepare do - require 'dry/validation' - require 'dry/monads/all' - require 'dry/matcher/result_matcher' - require 'dry/events' - end - - start do - target.start(:settings) - target.start(:persistence) + require "dry/validation" + require "dry/monads/all" + require "dry/matcher/result_matcher" + require "active_support" + require "active_support/core_ext/object/blank" + require "active_support/time" + require "dry/events" Dry::Schema.load_extensions(:hints) Dry::Schema.load_extensions(:info) @@ -19,4 +17,18 @@ Dry::Validation.load_extensions(:monads) Dry::Types.load_extensions(:monads) end + + start do + target.start(:settings) + target.start(:events) + target.start(:logger) + target.start(:persistence) + end + + stop do + target.stop(:settings) + target.stop(:events) + target.stop(:logger) + target.stop(:persistence) + end end diff --git a/system/providers/db.rb b/system/providers/db.rb index 903a344..128e2d7 100644 --- a/system/providers/db.rb +++ b/system/providers/db.rb @@ -4,8 +4,8 @@ # and registers it with our application under the container key. AuctionFunCore::Application.register_provider(:db) do prepare do - require 'rom' - require 'rom-sql' + require "rom" + require "rom-sql" end start do @@ -14,13 +14,13 @@ extensions: %i[pg_array pg_json pg_enum] ) migrator = ROM::SQL::Migration::Migrator.new( - connection, path: Pathname.new(AuctionFunCore::Application.root.join('db/migrate')) + connection, path: Pathname.new(AuctionFunCore::Application.root.join("db/migrate")) ) register(:db_connection, connection) register(:db_config, ROM::Configuration.new(:sql, connection, migrator: migrator)) end stop do - container['db_connection'].disconnect + container["db_connection"].disconnect end end diff --git a/system/providers/events.rb b/system/providers/events.rb new file mode 100644 index 0000000..ad52693 --- /dev/null +++ b/system/providers/events.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +AuctionFunCore::Application.register_provider(:events) do + start do + register(:event, AuctionFunCore::Events::App.new) + register(:listener, AuctionFunCore::Events::Listener.new) + register(:subscription, event.subscribe(listener)) + end + + stop do + container["subscription"].unsubscribe(listener) + end +end diff --git a/system/providers/logger.rb b/system/providers/logger.rb new file mode 100644 index 0000000..bfa896e --- /dev/null +++ b/system/providers/logger.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +AuctionFunCore::Application.register_provider(:logger) do + prepare do + require "logger" + end + + start do + register(:logger, AuctionFunCore::Application[:settings].logger) + end +end diff --git a/system/providers/persistence.rb b/system/providers/persistence.rb index 8dda44b..577aed7 100644 --- a/system/providers/persistence.rb +++ b/system/providers/persistence.rb @@ -4,8 +4,8 @@ # and registers it with our application under the container key. AuctionFunCore::Application.register_provider(:persistence) do prepare do - require 'rom' - require 'rom-sql' + require "rom" + require "rom-sql" end start do diff --git a/system/providers/settings.rb b/system/providers/settings.rb index eacd1e9..c80a3db 100644 --- a/system/providers/settings.rb +++ b/system/providers/settings.rb @@ -1,15 +1,19 @@ # frozen_string_literal: true -require 'dry/system/provider_sources' +require "dry/system/provider_sources" # This file uses the rom gem to define a database configuration container # and registers it with our application under the container key. AuctionFunCore::Application.register_provider(:settings, from: :dry_system) do before :prepare do - require 'money' + require "money" end settings do - setting :database_url, constructor: Dry::Types['string'].constrained(filled: true) + setting :database_url, constructor: Dry::Types["string"].constrained(filled: true) + setting :logger, default: Logger.new($stdout) + setting :logger_level, default: :info, constructor: Dry::Types["symbol"] + .constructor { |value| value.to_s.downcase.to_sym } + .enum(:trace, :unknown, :error, :fatal, :warn, :info, :debug) end end