diff --git a/Gemfile b/Gemfile index eb55e3a..a42fad6 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ ruby "2.7.4" gem "pg", "~> 1.1" gem "puma", "~> 5.0" gem "rails", "~> 6.1.4" +gem "rails-i18n", "~> 6.0" gem "sass-rails", ">= 6" gem "turbolinks", "~> 5" gem "webpacker", "~> 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 0f7b1f1..e0b38e7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -216,6 +216,9 @@ GEM ruby-graphviz (~> 1.2) rails-html-sanitizer (1.3.0) loofah (~> 2.3) + rails-i18n (6.0.0) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 7) railties (6.1.4) actionpack (= 6.1.4) activesupport (= 6.1.4) @@ -351,6 +354,7 @@ DEPENDENCIES pundit rails (~> 6.1.4) rails-erd + rails-i18n (~> 6.0) rspec-graphql_matchers (~> 1.3) rspec-rails rubocop-rails diff --git a/app/graphql/inputs/create_buy_crypto_order_attributes_input.rb b/app/graphql/inputs/create_buy_crypto_order_attributes_input.rb new file mode 100644 index 0000000..3b3f37e --- /dev/null +++ b/app/graphql/inputs/create_buy_crypto_order_attributes_input.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module Inputs + class CreateBuyCryptoOrderAttributesInput < Types::BaseInputObject + argument :currency_id, ID, required: true + argument :amount_cents, Integer, "Amount to be paid", required: true + end +end diff --git a/app/graphql/inputs/create_sell_crypto_order_attributes_input.rb b/app/graphql/inputs/create_sell_crypto_order_attributes_input.rb new file mode 100644 index 0000000..e6da70f --- /dev/null +++ b/app/graphql/inputs/create_sell_crypto_order_attributes_input.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module Inputs + class CreateSellCryptoOrderAttributesInput < Types::BaseInputObject + argument :currency_id, ID, required: true + argument :amount, String, "Amount to be paid", required: true + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index e589946..207afde 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -6,8 +6,16 @@ module Mutations input_object_class Types::BaseInputObject object_class Types::BaseObject - field :errors, [String], + field :errors, [Types::RecordInvalidType], null: true, description: "Errors encountered during execution of the mutation." + + def current_user + context[:current_user] + end + + def decode_id(encoded_id) + GraphQL::Schema::UniqueWithinType.decode(encoded_id).last + end end end diff --git a/app/graphql/mutations/create_buy_crypto_order.rb b/app/graphql/mutations/create_buy_crypto_order.rb new file mode 100644 index 0000000..9efb152 --- /dev/null +++ b/app/graphql/mutations/create_buy_crypto_order.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module Mutations + class CreateBuyCryptoOrder < BaseMutation + field :order, Types::BuyCryptoOrderType, null: true + + argument :order, Inputs::CreateBuyCryptoOrderAttributesInput, required: true + + def resolve(order:) + currency_id = decode_id(order[:currency_id]) + + ActiveRecord::Base.transaction do + current_user.fiat_balance.withdrawal!(order[:amount_cents]) + + record = BuyCryptoOrder.create!( + paid_amount_cents: order[:amount_cents], + currency_id: currency_id, + user_id: current_user.id, + ) + + { order: record } + rescue ActiveRecord::RecordInvalid => e + { errors: Resolvers::ModelErrors.from_active_record_model(e.record) } + end + end + end +end diff --git a/app/graphql/mutations/create_sell_crypto_order.rb b/app/graphql/mutations/create_sell_crypto_order.rb new file mode 100644 index 0000000..9b70114 --- /dev/null +++ b/app/graphql/mutations/create_sell_crypto_order.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module Mutations + class CreateSellCryptoOrder < BaseMutation + field :order, Types::SellCryptoOrderType, null: true + + argument :order, Inputs::CreateSellCryptoOrderAttributesInput, required: true + + def resolve(order:) + currency_id = decode_id(order[:currency_id]) + amount = BigDecimal(order[:amount]) + + ActiveRecord::Base.transaction do + current_user.balances.find_by!(currency_id: currency_id) + .withdrawal!(amount) + + record = SellCryptoOrder.create( + paid_amount: amount, + currency_id: currency_id, + user_id: current_user.id, + ) + + { order: record } + rescue ActiveRecord::RecordInvalid => e + { errors: Resolvers::ModelErrors.from_active_record_model(e.record) } + end + end + end +end diff --git a/app/graphql/resolvers/model_errors.rb b/app/graphql/resolvers/model_errors.rb new file mode 100644 index 0000000..8156a01 --- /dev/null +++ b/app/graphql/resolvers/model_errors.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +module Resolvers + class ModelErrors + attr_reader :full_messages, :field_name, :messages, :path + + def initialize(args) + @full_messages = args[:full_messages] + @field_name = args[:field_name] + @messages = args[:messages] + @path = args[:path] + end + + def self.from_active_record_model(model) + return if model&.errors.blank? + + model.errors.messages.map do |field, messages| + new( + full_messages: model.errors.full_messages_for(field), + field_name: field, + messages: messages, + path: ["attributes", field] + ) + end + end + + def self.from_active_record_model_errors(errors) + return if errors.blank? + + errors.messages.map do |field, messages| + new( + full_messages: errors.full_messages_for(field), + field_name: field, + messages: messages, + path: ["attributes", field] + ) + end + end + end +end diff --git a/app/graphql/types/buy_crypto_order_type.rb b/app/graphql/types/buy_crypto_order_type.rb new file mode 100644 index 0000000..1c28420 --- /dev/null +++ b/app/graphql/types/buy_crypto_order_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Types + class BuyCryptoOrderType < Types::BaseObject + implements GraphQL::Types::Relay::Node + global_id_field :id + + graphql_name "BuyCryptoOrder" + + field :id, ID, null: false + + field :currency, CurrencyType, null: false + def currency + dataloader.with(Dataloader::Source, Currency).load(object.currency_id) + end + + field :status, ProcessStatusEnum, null: false + field :paid_amount_cents, Integer, null: false + field :received_amount, String, null: true + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end +end diff --git a/app/graphql/types/fiat_balance_type.rb b/app/graphql/types/fiat_balance_type.rb index 65eaa24..be79b3e 100644 --- a/app/graphql/types/fiat_balance_type.rb +++ b/app/graphql/types/fiat_balance_type.rb @@ -9,7 +9,5 @@ module Types field :id, ID, null: false field :amount_cents, Integer, null: false field :amount_currency, String, null: false - field :created_at, GraphQL::Types::ISO8601DateTime, null: false - field :updated_at, GraphQL::Types::ISO8601DateTime, null: false end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index c928833..a1405bb 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true module Types class MutationType < Types::BaseObject + field :create_sell_crypto_order, mutation: Mutations::CreateSellCryptoOrder + field :create_buy_crypto_order, mutation: Mutations::CreateBuyCryptoOrder end end diff --git a/app/graphql/types/process_status_enum.rb b/app/graphql/types/process_status_enum.rb new file mode 100644 index 0000000..9d5704c --- /dev/null +++ b/app/graphql/types/process_status_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module Types + class ProcessStatusEnum < Types::BaseEnum + graphql_name "ProcessStatus" + + value "PROCESSING", value: "processing" + value "COMPLETED", value: "completed" + value "CANCELED", value: "canceled" + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index a87b9e2..480509a 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -18,5 +18,15 @@ module Types def fiat_balances Pundit.policy_scope(current_user, FiatBalance) end + + field :sell_crypto_orders, SellCryptoOrderType.connection_type, null: false + def sell_crypto_orders + [] + end + + field :buy_crypto_orders, BuyCryptoOrderType.connection_type, null: false + def buy_crypto_orders + [] + end end end diff --git a/app/graphql/types/record_invalid_type.rb b/app/graphql/types/record_invalid_type.rb new file mode 100644 index 0000000..42ea150 --- /dev/null +++ b/app/graphql/types/record_invalid_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class RecordInvalidType < Types::BaseObject + graphql_name "RecordInvalid" + field :full_messages, [String], null: false + field :field_name, String, null: true + field :messages, [String], null: true + field :path, [String], null: true + end +end diff --git a/app/graphql/types/sell_crypto_order_type.rb b/app/graphql/types/sell_crypto_order_type.rb new file mode 100644 index 0000000..c70bcde --- /dev/null +++ b/app/graphql/types/sell_crypto_order_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Types + class SellCryptoOrderType < Types::BaseObject + implements GraphQL::Types::Relay::Node + global_id_field :id + + graphql_name "SellCryptoOrder" + + field :id, ID, null: false + + field :currency, CurrencyType, null: false + def currency + dataloader.with(Dataloader::Source, Currency).load(object.currency_id) + end + + field :status, ProcessStatusEnum, null: false + field :paid_amount, String, null: false + field :received_amount_cents, Integer, null: true + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end +end diff --git a/app/graphql/x_stake_schema.rb b/app/graphql/x_stake_schema.rb index e95c99a..e7c28a0 100644 --- a/app/graphql/x_stake_schema.rb +++ b/app/graphql/x_stake_schema.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class XStakeSchema < GraphQL::Schema - # mutation(Types::MutationType) + mutation(Types::MutationType) query(Types::QueryType) use GraphQL::Dataloader diff --git a/app/javascript/__generated__/schema.graphql b/app/javascript/__generated__/schema.graphql index c945e2f..16763b3 100644 --- a/app/javascript/__generated__/schema.graphql +++ b/app/javascript/__generated__/schema.graphql @@ -34,19 +34,24 @@ type BalanceEdge { node: Balance! } -type Currency implements Node { +type BuyCryptoOrder implements Node { + createdAt: ISO8601DateTime! + currency: Currency! id: ID! - name: String! + paidAmountCents: Int! + receivedAmount: Float + status: ProcessStatus! + updatedAt: ISO8601DateTime! } """ -The connection type for Currency. +The connection type for BuyCryptoOrder. """ -type CurrencyConnection { +type BuyCryptoOrderConnection { """ A list of edges. """ - edges: [CurrencyEdge!]! + edges: [BuyCryptoOrderEdge!]! """ Information to aid in pagination. @@ -57,7 +62,7 @@ type CurrencyConnection { """ An edge in a connection. """ -type CurrencyEdge { +type BuyCryptoOrderEdge { """ A cursor for use in pagination. """ @@ -66,7 +71,82 @@ type CurrencyEdge { """ The item at the end of the edge. """ - node: Currency! + node: BuyCryptoOrder! +} + +input CreateBuyCryptoOrderAttributesInput { + """ + Amount to be paid + """ + amount: Float! + currencyId: ID! +} + +""" +Autogenerated input type of CreateBuyCryptoOrder +""" +input CreateBuyCryptoOrderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + order: CreateBuyCryptoOrderAttributesInput! +} + +""" +Autogenerated return type of CreateBuyCryptoOrder +""" +type CreateBuyCryptoOrderPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!] + order: BuyCryptoOrder! +} + +input CreateSellCryptoOrderAttributesInput { + """ + Amount to be paid + """ + amount: Float! + currencyId: ID! +} + +""" +Autogenerated input type of CreateSellCryptoOrder +""" +input CreateSellCryptoOrderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + order: CreateSellCryptoOrderAttributesInput! +} + +""" +Autogenerated return type of CreateSellCryptoOrder +""" +type CreateSellCryptoOrderPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!] + order: SellCryptoOrder! +} + +type Currency implements Node { + id: ID! + name: String! } type FiatBalance implements Node { @@ -112,6 +192,21 @@ An ISO 8601-encoded datetime """ scalar ISO8601DateTime +type Mutation { + createBuyCryptoOrder( + """ + Parameters for CreateBuyCryptoOrder + """ + input: CreateBuyCryptoOrderInput! + ): CreateBuyCryptoOrderPayload + createSellCryptoOrder( + """ + Parameters for CreateSellCryptoOrder + """ + input: CreateSellCryptoOrderInput! + ): CreateSellCryptoOrderPayload +} + """ An object with an ID. """ @@ -147,6 +242,12 @@ type PageInfo { startCursor: String } +enum ProcessStatus { + CANCELED + COMPLETED + PROCESSING +} + type Query { balances( """ @@ -169,7 +270,7 @@ type Query { """ last: Int ): BalanceConnection! - currencies( + buyCryptoOrders( """ Returns the elements in the list that come after the specified cursor. """ @@ -189,7 +290,7 @@ type Query { Returns the last _n_ elements from the list. """ last: Int - ): CurrencyConnection! + ): BuyCryptoOrderConnection! currentUser: User fiatBalances( """ @@ -232,6 +333,67 @@ type Query { """ ids: [ID!]! ): [Node]! + sellCryptoOrders( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): SellCryptoOrderConnection! +} + +type SellCryptoOrder implements Node { + createdAt: ISO8601DateTime! + currency: Currency! + id: ID! + paidAmount: Float! + receivedAmountCents: Int + status: ProcessStatus! + updatedAt: ISO8601DateTime! +} + +""" +The connection type for SellCryptoOrder. +""" +type SellCryptoOrderConnection { + """ + A list of edges. + """ + edges: [SellCryptoOrderEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type SellCryptoOrderEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: SellCryptoOrder! } type User { diff --git a/app/models/balance.rb b/app/models/balance.rb index 8dfc70b..7f98371 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -25,5 +25,13 @@ class Balance < ApplicationRecord belongs_to :user belongs_to :currency - validates :amount, presence: true + validates :amount, presence: true, numericality: { greater_than_or_equal_to: 0 } + + def withdrawal!(value) + update!(amount: amount - value) + end + + def deposit!(value) + update!(amount: amount + value) + end end diff --git a/app/models/fiat_balance.rb b/app/models/fiat_balance.rb index 8722278..b64c62a 100644 --- a/app/models/fiat_balance.rb +++ b/app/models/fiat_balance.rb @@ -24,7 +24,17 @@ class FiatBalance < ApplicationRecord monetize :amount_cents + validates :amount_cents, numericality: { greater_than_or_equal_to: 0 } + def amount_formatted amount.format end + + def withdrawal!(value) + update!(amount_cents: amount_cents - value) + end + + def deposit!(value) + update!(amount_cents: amount_cents + value) + end end diff --git a/app/models/user.rb b/app/models/user.rb index cbc1d8d..26d827a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,7 +25,7 @@ class User < ApplicationRecord :recoverable, :rememberable, :validatable has_many :documents, class_name: "UserDocument", dependent: :destroy - has_one :balance, dependent: :restrict_with_error + has_many :balances, dependent: :restrict_with_error has_one :fiat_balance, dependent: :restrict_with_error validates :first_name, :last_name, :email, presence: true diff --git a/config/locales/activerecord.yml b/config/locales/pt-BR.yml similarity index 66% rename from config/locales/activerecord.yml rename to config/locales/pt-BR.yml index 03538e7..ee233df 100644 --- a/config/locales/activerecord.yml +++ b/config/locales/pt-BR.yml @@ -32,6 +32,18 @@ pt-BR: fiat_balance: amount_formatted: Quantia + amount_cents: Quantia currency: - name: Nome \ No newline at end of file + name: Nome + + errors: + models: + balance: + attributes: + amount: + greater_than_or_equal_to: "saldo insuficiente" + fiat_balance: + attributes: + amount_cents: + greater_than_or_equal_to: "saldo insuficiente" \ No newline at end of file diff --git a/spec/factories/fiat_balances.rb b/spec/factories/fiat_balances.rb index 37ac2a4..307c2bb 100644 --- a/spec/factories/fiat_balances.rb +++ b/spec/factories/fiat_balances.rb @@ -23,6 +23,6 @@ FactoryBot.define do factory :fiat_balance do association :user amount_currency { "BRL" } - amount_cents { Faker::Number.number(digits: 10) } + amount_cents { Faker::Number.number(digits: 4) } end end diff --git a/spec/graphql/inputs/create_buy_crypto_order_attributes_input_spec.rb b/spec/graphql/inputs/create_buy_crypto_order_attributes_input_spec.rb new file mode 100644 index 0000000..84a1cbc --- /dev/null +++ b/spec/graphql/inputs/create_buy_crypto_order_attributes_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Inputs::CreateBuyCryptoOrderAttributesInput do + subject { described_class } + + describe "fields" do + it { is_expected.to(accept_argument("currency_id").of_type("ID!")) } + it { is_expected.to(accept_argument("amount_cents").of_type("Int!")) } + end +end diff --git a/spec/graphql/inputs/create_sell_crypto_order_attributes_input_spec.rb b/spec/graphql/inputs/create_sell_crypto_order_attributes_input_spec.rb new file mode 100644 index 0000000..84607d7 --- /dev/null +++ b/spec/graphql/inputs/create_sell_crypto_order_attributes_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Inputs::CreateSellCryptoOrderAttributesInput do + subject { described_class } + + describe "fields" do + it { is_expected.to(accept_argument("currency_id").of_type("ID!")) } + it { is_expected.to(accept_argument("amount").of_type("String!")) } + end +end diff --git a/spec/graphql/mutations/create_buy_crypto_order_spec.rb b/spec/graphql/mutations/create_buy_crypto_order_spec.rb new file mode 100644 index 0000000..86ebbb5 --- /dev/null +++ b/spec/graphql/mutations/create_buy_crypto_order_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe(Mutations::CreateBuyCryptoOrder, type: :mutation) do + let(:query_string) do + <<~GQL + mutation($currencyId: ID!, $amountCents: Int!) { + createBuyCryptoOrder(input: { + order: { + currencyId: $currencyId, + amountCents: $amountCents, + } + }) { + errors { + fullMessages + fieldName + messages + path + } + order { + status + paidAmountCents + receivedAmount + currency { + name + } + } + } + } + GQL + end + + context "when the user has enough balance" do + it "withdraws from his account and creates a buy order" do + currency = create(:currency) + user = create( + :user, + fiat_balance: build( + :fiat_balance, + amount_cents: 100_00 + ), + ) + + currency_global_id = GraphQL::Schema::UniqueWithinType.encode("Currency", currency.id) + + variables = { + "currencyId": currency_global_id, + "amountCents": 90_00, + } + + context = { current_user: user } + + result = XStakeSchema.execute( + query_string, + variables: variables, + context: context + ).to_h.with_indifferent_access + + expect(result).to(eq({ + "data" => { + "createBuyCryptoOrder" => { + "errors" => nil, + "order" => { + "status" => "PROCESSING", + "paidAmountCents" => 90_00, + "receivedAmount" => nil, + "currency" => { + "name" => "CAKE", + }, + }, + }, + }, + })) + + expect(user.fiat_balance.reload.amount_cents).to(eq(10_00)) + end + end + + context "when the user does not have enough balance" do + it "returns withdrawl error" do + currency = create(:currency) + user = create( + :user, + fiat_balance: build( + :fiat_balance, + amount_cents: 80_00 + ), + ) + + currency_global_id = GraphQL::Schema::UniqueWithinType.encode("Currency", currency.id) + + variables = { + "currencyId": currency_global_id, + "amountCents": 90_00, + } + + context = { current_user: user } + + result = XStakeSchema.execute( + query_string, + variables: variables, + context: context + ).to_h.with_indifferent_access + + expect(result).to(eq({ + "data" => { + "createBuyCryptoOrder" => { + "errors" => [{ + "fullMessages" => ["Quantia saldo insuficiente"], + "fieldName" => "amount_cents", + "messages" => ["saldo insuficiente"], + "path" => ["attributes", "amount_cents"], + }], + "order" => nil, + }, + }, + })) + end + end +end diff --git a/spec/graphql/mutations/create_sell_crypto_order_spec.rb b/spec/graphql/mutations/create_sell_crypto_order_spec.rb new file mode 100644 index 0000000..b817e14 --- /dev/null +++ b/spec/graphql/mutations/create_sell_crypto_order_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe(Mutations::CreateSellCryptoOrder, type: :mutation) do + let(:query_string) do + <<~GQL + mutation($currencyId: ID!, $amount: String!) { + createSellCryptoOrder(input: { + order: { + currencyId: $currencyId, + amount: $amount, + } + }) { + errors { + fullMessages + fieldName + messages + path + } + order { + status + paidAmount + receivedAmountCents + currency { + name + } + } + } + } + GQL + end + + context "when the user has enough balance" do + it "withdraws from his account and creates a buy order" do + currency = create(:currency) + user = create( + :user, + balances: [ + build(:balance, currency: currency, amount: 1.0034), + ] + ) + + currency_global_id = GraphQL::Schema::UniqueWithinType.encode("Currency", currency.id) + + variables = { + "currencyId": currency_global_id, + "amount": "0.80", + } + + context = { current_user: user } + + result = XStakeSchema.execute( + query_string, + variables: variables, + context: context + ).to_h.with_indifferent_access + + expect(result).to(eq({ + "data" => { + "createSellCryptoOrder" => { + "errors" => nil, + "order" => { + "status" => "PROCESSING", + "paidAmount" => "0.8", + "receivedAmountCents" => nil, + "currency" => { + "name" => "CAKE", + }, + }, + }, + }, + })) + + expect(user.balances.first.reload.amount.to_s).to(eq("0.2034")) + end + end + + context "when the user does not have enough balance" do + it "returns withdrawl error" do + currency = create(:currency) + user = create( + :user, + balances: [ + build(:balance, currency: currency, amount: 0.0034), + ] + ) + + currency_global_id = GraphQL::Schema::UniqueWithinType.encode("Currency", currency.id) + + variables = { + "currencyId": currency_global_id, + "amount": "0.80", + } + + context = { current_user: user } + + result = XStakeSchema.execute( + query_string, + variables: variables, + context: context + ).to_h.with_indifferent_access + + expect(result).to(eq({ + "data" => { + "createSellCryptoOrder" => { + "errors" => [{ + "fullMessages" => ["Quantia saldo insuficiente"], + "fieldName" => "amount", + "messages" => ["saldo insuficiente"], + "path" => ["attributes", "amount"], + }], + "order" => nil, + }, + }, + })) + + expect(user.balances.first.reload.amount.to_s).to(eq("0.0034")) + end + end +end diff --git a/spec/graphql/types/buy_crypto_order_type_spec.rb b/spec/graphql/types/buy_crypto_order_type_spec.rb new file mode 100644 index 0000000..27ed225 --- /dev/null +++ b/spec/graphql/types/buy_crypto_order_type_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "rails_helper" + +RSpec.describe(Types::BuyCryptoOrderType) do + subject { described_class } + + describe "arguments" do + it { is_expected.to(have_a_field(:id).of_type("ID!")) } + it { is_expected.to(have_a_field(:currency).of_type("Currency!")) } + it { is_expected.to(have_a_field(:paid_amount_cents).of_type("Int!")) } + it { is_expected.to(have_a_field(:received_amount).of_type("String")) } + it { is_expected.to(have_a_field(:status).of_type("ProcessStatus!")) } + it { is_expected.to(have_a_field(:created_at).of_type("ISO8601DateTime!")) } + it { is_expected.to(have_a_field(:updated_at).of_type("ISO8601DateTime!")) } + end +end diff --git a/spec/graphql/types/process_status_enum_spec.rb b/spec/graphql/types/process_status_enum_spec.rb new file mode 100644 index 0000000..8bcb0c5 --- /dev/null +++ b/spec/graphql/types/process_status_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Types::ProcessStatusEnum do + describe "values" do + it { expect(described_class.values.keys).to(match(["PROCESSING", "COMPLETED", "CANCELED"])) } + end +end diff --git a/spec/graphql/types/record_invalid_type_spec.rb b/spec/graphql/types/record_invalid_type_spec.rb new file mode 100644 index 0000000..98416ce --- /dev/null +++ b/spec/graphql/types/record_invalid_type_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Types::RecordInvalidType do + subject { described_class } + + describe "fields" do + it { is_expected.to(have_field(:full_messages).of_type("[String!]!")) } + it { is_expected.to(have_field(:field_name).of_type("String")) } + it { is_expected.to(have_field(:messages).of_type("[String!]")) } + it { is_expected.to(have_field(:path).of_type("[String!]")) } + end +end diff --git a/spec/graphql/types/sell_crypto_order_type_spec.rb b/spec/graphql/types/sell_crypto_order_type_spec.rb new file mode 100644 index 0000000..65b2e67 --- /dev/null +++ b/spec/graphql/types/sell_crypto_order_type_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "rails_helper" + +RSpec.describe(Types::SellCryptoOrderType) do + subject { described_class } + + describe "arguments" do + it { is_expected.to(have_a_field(:id).of_type("ID!")) } + it { is_expected.to(have_a_field(:currency).of_type("Currency!")) } + it { is_expected.to(have_a_field(:paid_amount).of_type("String!")) } + it { is_expected.to(have_a_field(:received_amount_cents).of_type("Int")) } + it { is_expected.to(have_a_field(:status).of_type("ProcessStatus!")) } + it { is_expected.to(have_a_field(:created_at).of_type("ISO8601DateTime!")) } + it { is_expected.to(have_a_field(:updated_at).of_type("ISO8601DateTime!")) } + end +end diff --git a/spec/models/admin_user_spec.rb b/spec/models/admin_user_spec.rb deleted file mode 100644 index a6218ff..0000000 --- a/spec/models/admin_user_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: admin_users -# -# id :bigint not null, primary key -# email :string default(""), not null -# encrypted_password :string default(""), not null -# remember_created_at :datetime -# reset_password_sent_at :datetime -# reset_password_token :string -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_admin_users_on_email (email) UNIQUE -# index_admin_users_on_reset_password_token (reset_password_token) UNIQUE -# -require "rails_helper" - -RSpec.describe(AdminUser, type: :model) do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/models/balance_spec.rb b/spec/models/balance_spec.rb index 67694fc..0a3c3cf 100644 --- a/spec/models/balance_spec.rb +++ b/spec/models/balance_spec.rb @@ -32,4 +32,32 @@ RSpec.describe(Balance, type: :model) do it { is_expected.to(belong_to(:user)) } it { is_expected.to(belong_to(:currency)) } end + + describe ".withdrwal!" do + context "when value is greater than the balance" do + it "raise ActiveRecord::RecordInvalid" do + balance = build(:balance, amount: 70.342) + + expect { balance.withdrawal!(80) }.to( + raise_error(ActiveRecord::RecordInvalid, "A validação falhou: Quantia saldo insuficiente") + ) + end + end + + context "when value is equals to the balance" do + it "returns true" do + balance = build(:balance, amount: 70.342) + + expect(balance.withdrawal!(70.342)).to(eq(true)) + end + end + + context "when value is smaller than the balance" do + it "returns true" do + balance = build(:balance, amount: 70.342) + + expect(balance.withdrawal!(20)).to(eq(true)) + end + end + end end diff --git a/spec/models/fiat_balance_spec.rb b/spec/models/fiat_balance_spec.rb index 704bfc1..957d7f1 100644 --- a/spec/models/fiat_balance_spec.rb +++ b/spec/models/fiat_balance_spec.rb @@ -25,4 +25,32 @@ RSpec.describe(FiatBalance, type: :model) do describe "associations" do it { is_expected.to(belong_to(:user)) } end + + describe ".withdrwal!" do + context "when value is greater than the balance" do + it "raise ActiveRecord::RecordInvalid" do + balance = build(:fiat_balance, amount_cents: 100_00) + + expect { balance.withdrawal!(100_50) }.to( + raise_error(ActiveRecord::RecordInvalid, "A validação falhou: Quantia saldo insuficiente") + ) + end + end + + context "when value is equals to the balance" do + it "returns true" do + balance = build(:fiat_balance, amount_cents: 100_00) + + expect(balance.withdrawal!(100_00)).to(eq(true)) + end + end + + context "when value is smaller than the balance" do + it "returns true" do + balance = build(:fiat_balance, amount_cents: 100_00) + + expect(balance.withdrawal!(90_00)).to(eq(true)) + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ce70b21..bc8481d 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -31,7 +31,7 @@ RSpec.describe(User, type: :model) do describe "associations" do it { is_expected.to(have_many(:documents)) } - it { is_expected.to(have_one(:balance)) } + it { is_expected.to(have_many(:balances)) } it { is_expected.to(have_one(:fiat_balance)) } end end diff --git a/spec/policies/balance_policy_spec.rb b/spec/policies/balance_policy_spec.rb index eb3c421..158e99e 100644 --- a/spec/policies/balance_policy_spec.rb +++ b/spec/policies/balance_policy_spec.rb @@ -2,5 +2,41 @@ require "rails_helper" RSpec.describe(BalancePolicy, type: :policy) do - pending "add some examples to (or delete) #{__FILE__}" + context "when user has balances" do + it "return only balances from a user" do + create(:balance) + create(:balance) + + user = build(:user) + balance = create(:balance, user: user) + + balances = BalancePolicy::Scope.new(user, Balance).resolve + + expect(balances).to(eq([balance])) + end + end + + context "when user has not balances" do + it "return empty array" do + create(:balance) + create(:balance) + + user = build(:user) + + balances = BalancePolicy::Scope.new(user, Balance).resolve + + expect(balances).to(eq([])) + end + end + + context "when user is nil" do + it "return empty array" do + create(:balance) + create(:balance) + + balances = BalancePolicy::Scope.new(nil, Balance).resolve + + expect(balances).to(eq([])) + end + end end diff --git a/spec/policies/fiat_balance_policy_spec.rb b/spec/policies/fiat_balance_policy_spec.rb index 9bf7e08..9fa3dce 100644 --- a/spec/policies/fiat_balance_policy_spec.rb +++ b/spec/policies/fiat_balance_policy_spec.rb @@ -2,5 +2,43 @@ require "rails_helper" RSpec.describe(FiatBalancePolicy, type: :policy) do - pending "add some examples to (or delete) #{__FILE__}" + describe "::Scope" do + context "when user has balances" do + it "return only balances from a user" do + create(:fiat_balance) + create(:fiat_balance) + + user = build(:user) + balance = create(:fiat_balance, user: user) + + balances = FiatBalancePolicy::Scope.new(user, FiatBalance).resolve + + expect(balances).to(eq([balance])) + end + end + + context "when user has not balances" do + it "return empty array" do + create(:fiat_balance) + create(:fiat_balance) + + user = build(:user) + + balances = FiatBalancePolicy::Scope.new(user, FiatBalance).resolve + + expect(balances).to(eq([])) + end + end + + context "when user is nil" do + it "return empty array" do + create(:fiat_balance) + create(:fiat_balance) + + balances = FiatBalancePolicy::Scope.new(nil, FiatBalance).resolve + + expect(balances).to(eq([])) + end + end + end end