diff --git a/.rubocop.yml b/.rubocop.yml index 2246bcf..a8841c9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,7 +5,6 @@ require: AllCops: TargetRubyVersion: 3.3.0 Exclude: - - "spec/**/*.rb" - "db/**/*" - "config/**/*" - "bin/**/*" @@ -17,20 +16,32 @@ Custom/PrivateMethodStyle: ################### # End Custom Cops # ################### +Layout/ArgumentAlignment: + EnforcedStyle: with_fixed_indentation Layout/IndentationWidth: Enabled: true Width: 2 +Layout/SpaceInsideBlockBraces: + EnforcedStyle: space Lint/UnusedMethodArgument: AutoCorrect: false +Metrics/AbcSize: + Exclude: + - "app/models/application_record.rb" Metrics/CyclomaticComplexity: Exclude: - "lib/rubocop/cop/custom/*.rb" Metrics/MethodLength: Exclude: - "lib/rubocop/cop/custom/*.rb" + - "app/models/application_record.rb" Metrics/PerceivedComplexity: Exclude: - "lib/rubocop/cop/custom/*.rb" +Naming/VariableNumber: + EnforcedStyle: snake_case +Rails/InverseOf: + Enabled: false Style/AccessModifierDeclarations: EnforcedStyle: inline Style/Documentation: diff --git a/Gemfile b/Gemfile index f3666a7..d5d95e8 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,8 @@ gem "rails", "~> 7.1.3", ">= 7.1.3.4" gem "pg", "~> 1.1" # Use the Puma web server [https://github.com/puma/puma] +gem "faker" +gem "money-rails" gem "puma", ">= 5.0" # Build JSON APIs with ease [https://github.com/rails/jbuilder] diff --git a/Gemfile.lock b/Gemfile.lock index c39eba5..f942826 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,8 @@ GEM factory_bot_rails (4.11.1) factory_bot (~> 4.11.1) railties (>= 3.0.0) + faker (3.3.1) + i18n (>= 1.8.11, < 2) faraday (2.9.0) faraday-net_http (>= 2.0, < 3.2) faraday-net_http (3.1.0) @@ -137,6 +139,15 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.24.1) + monetize (1.13.0) + money (~> 6.12) + money (6.19.0) + i18n (>= 0.6.4, <= 2) + money-rails (1.15.0) + activesupport (>= 3.0) + monetize (~> 1.9) + money (~> 6.13) + railties (>= 3.0) msgpack (1.7.2) mutex_m (0.2.0) net-http (0.4.1) @@ -295,8 +306,10 @@ DEPENDENCIES database_cleaner debug factory_bot_rails (~> 4.0) + faker faraday figaro + money-rails pg (~> 1.1) pry-rails puma (>= 5.0) diff --git a/app/models/address.rb b/app/models/address.rb new file mode 100644 index 0000000..80eb353 --- /dev/null +++ b/app/models/address.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Address < ApplicationRecord + has_many :driver_addresses, dependent: :destroy + has_many :ride_origins, class_name: "Ride", foreign_key: "from_address_id", dependent: nil, inverse_of: :from_address + has_many :ride_destinations, class_name: "Ride", foreign_key: "to_address_id", dependent: nil, inverse_of: :to_address + + validates :line_1, :city, :state, :zip_code, :place_id, :latitude, :longitude, presence: true + + def rides + Ride.by_address(id) + end +end diff --git a/app/models/driver.rb b/app/models/driver.rb new file mode 100644 index 0000000..9c00472 --- /dev/null +++ b/app/models/driver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Driver < ApplicationRecord + has_many :driver_addresses, dependent: :destroy + has_one :current_driver_address, + -> { where(current: true) }, + class_name: "DriverAddress", + dependent: :destroy, + inverse_of: :driver + has_one :current_address, through: :current_driver_address, source: :driver, dependent: :destroy + + validates :first_name, :last_name, presence: true +end diff --git a/app/models/driver_address.rb b/app/models/driver_address.rb new file mode 100644 index 0000000..d6162b0 --- /dev/null +++ b/app/models/driver_address.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DriverAddress < ApplicationRecord + belongs_to :driver + belongs_to :address + + validates :current, + if: -> { current }, + uniqueness: { + scope: :driver_id, + message: "can only have one current Driver" + }, + inclusion: { in: [true, false] } +end diff --git a/app/models/ride.rb b/app/models/ride.rb new file mode 100644 index 0000000..eea732c --- /dev/null +++ b/app/models/ride.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Ride < ApplicationRecord + belongs_to :driver, optional: true + belongs_to :from_address, class_name: "Address" + belongs_to :to_address, class_name: "Address" + + validates :duration, :distance, :commute_duration, :amount_cents, presence: true + monetize :amount_cents, + as: :amount, + allow_nil: false, + numericality: { + greater_than_or_equal_to: 0 + } + + scope :by_address, ->(address_id) { + where(from_address_id: address_id).or(where(to_address_id: address_id)) + } +end diff --git a/config/initializers/money.rb b/config/initializers/money.rb new file mode 100644 index 0000000..21ecaf7 --- /dev/null +++ b/config/initializers/money.rb @@ -0,0 +1,115 @@ +# encoding : utf-8 + +MoneyRails.configure do |config| + + # To set the default currency + # + config.default_currency = :usd + + # Set default bank object + # + # Example: + # config.default_bank = EuCentralBank.new + + # Add exchange rates to current money bank object. + # (The conversion rate refers to one direction only) + # + # Example: + # config.add_rate "USD", "CAD", 1.24515 + # config.add_rate "CAD", "USD", 0.803115 + + # To handle the inclusion of validations for monetized fields + # The default value is true + # + config.include_validations = true + + # Default ActiveRecord migration configuration values for columns: + # + # config.amount_column = { prefix: '', # column name prefix + # postfix: '_cents', # column name postfix + # column_name: nil, # full column name (overrides prefix, postfix and accessor name) + # type: :integer, # column type + # present: true, # column will be created + # null: false, # other options will be treated as column options + # default: 0 + # } + # + # config.currency_column = { prefix: '', + # postfix: '_currency', + # column_name: nil, + # type: :string, + # present: true, + # null: false, + # default: 'USD' + # } + + # Register a custom currency + # + # Example: + # config.register_currency = { + # priority: 1, + # iso_code: "EU4", + # name: "Euro with subunit of 4 digits", + # symbol: "€", + # symbol_first: true, + # subunit: "Subcent", + # subunit_to_unit: 10000, + # thousands_separator: ".", + # decimal_mark: "," + # } + + # Specify a rounding mode + # Any one of: + # + # BigDecimal::ROUND_UP, + # BigDecimal::ROUND_DOWN, + # BigDecimal::ROUND_HALF_UP, + # BigDecimal::ROUND_HALF_DOWN, + # BigDecimal::ROUND_HALF_EVEN, + # BigDecimal::ROUND_CEILING, + # BigDecimal::ROUND_FLOOR + # + # set to BigDecimal::ROUND_HALF_EVEN by default + # + # config.rounding_mode = BigDecimal::ROUND_HALF_UP + + # Set default money format globally. + # Default value is nil meaning "ignore this option". + # Example: + # + # config.default_format = { + # no_cents_if_whole: nil, + # symbol: nil, + # sign_before_symbol: nil + # } + + # If you would like to use I18n localization (formatting depends on the + # locale): + # config.locale_backend = :i18n + # + # Example (using default localization from rails-i18n): + # + # I18n.locale = :en + # Money.new(10_000_00, 'USD').format # => $10,000.00 + # I18n.locale = :es + # Money.new(10_000_00, 'USD').format # => $10.000,00 + # + # For the legacy behaviour of "per currency" localization (formatting depends + # only on currency): + # config.locale_backend = :currency + # + # Example: + # Money.new(10_000_00, 'USD').format # => $10,000.00 + # Money.new(10_000_00, 'EUR').format # => €10.000,00 + # + # In case you don't need localization and would like to use default values + # (can be redefined using config.default_format): + # config.locale_backend = nil + + # Set default raise_error_on_money_parsing option + # It will be raise error if assigned different currency + # The default value is false + # + # Example: + # config.raise_error_on_money_parsing = false +end diff --git a/db/migrate/20240702182238_create_addresses.rb b/db/migrate/20240702182238_create_addresses.rb new file mode 100644 index 0000000..c0c110a --- /dev/null +++ b/db/migrate/20240702182238_create_addresses.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateAddresses < ActiveRecord::Migration[7.1] + def change + create_table :addresses do |t| + t.string :line_1, null: false + t.string :line_2, null: true + t.string :city, null: false + t.string :state, index: true, null: false + t.string :zip_code, index: true, null: false + t.float :latitude, null: false + t.float :longitude, null: false + t.string :place_id, null: true, index: true + + t.timestamps + end + add_index :addresses, %i[city state] + end +end diff --git a/db/migrate/20240702184830_create_drivers.rb b/db/migrate/20240702184830_create_drivers.rb new file mode 100644 index 0000000..1f45deb --- /dev/null +++ b/db/migrate/20240702184830_create_drivers.rb @@ -0,0 +1,10 @@ +class CreateDrivers < ActiveRecord::Migration[7.1] + def change + create_table :drivers do |t| + t.string :first_name, null: false + t.string :last_name, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20240702193305_create_rides.rb b/db/migrate/20240702193305_create_rides.rb new file mode 100644 index 0000000..78a8251 --- /dev/null +++ b/db/migrate/20240702193305_create_rides.rb @@ -0,0 +1,14 @@ +class CreateRides < ActiveRecord::Migration[7.1] + def change + create_table :rides do |t| + t.float :duration, index: true, null: false + t.float :distance, index: true, null: false + t.float :commute_duration, index: true, null: false + t.monetize :amount + t.references :driver, null: true, index: true + t.references :from_address, foreign_key: {to_table: :addresses}, index: true, null: false + t.references :to_address, foreign_key: {to_table: :addresses}, index: true, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20240702210002_create_driver_addresses.rb b/db/migrate/20240702210002_create_driver_addresses.rb new file mode 100644 index 0000000..7ed08e8 --- /dev/null +++ b/db/migrate/20240702210002_create_driver_addresses.rb @@ -0,0 +1,11 @@ +class CreateDriverAddresses < ActiveRecord::Migration[7.1] + def change + create_table :driver_addresses do |t| + t.boolean :current, null: false, default: false + t.references :driver, null: false, foreign_key: true + t.references :address, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240702210854_add_uniq_index_to_driver_addresses.rb b/db/migrate/20240702210854_add_uniq_index_to_driver_addresses.rb new file mode 100644 index 0000000..637419f --- /dev/null +++ b/db/migrate/20240702210854_add_uniq_index_to_driver_addresses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUniqIndexToDriverAddresses < ActiveRecord::Migration[7.1] + def change + add_index :driver_addresses, %i[current driver_id], unique: true, where: "(current IS TRUE)" + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..c9ad6d7 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,75 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2024_07_02_210854) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "addresses", force: :cascade do |t| + t.string "line_1", null: false + t.string "line_2" + t.string "city", null: false + t.string "state", null: false + t.string "zip_code", null: false + t.float "latitude", null: false + t.float "longitude", null: false + t.string "place_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["city", "state"], name: "index_addresses_on_city_and_state" + t.index ["place_id"], name: "index_addresses_on_place_id" + t.index ["state"], name: "index_addresses_on_state" + t.index ["zip_code"], name: "index_addresses_on_zip_code" + end + + create_table "driver_addresses", force: :cascade do |t| + t.boolean "current", default: false, null: false + t.bigint "driver_id", null: false + t.bigint "address_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["address_id"], name: "index_driver_addresses_on_address_id" + t.index ["current", "driver_id"], name: "index_driver_addresses_on_current_and_driver_id", unique: true, where: "(current IS TRUE)" + t.index ["driver_id"], name: "index_driver_addresses_on_driver_id" + end + + create_table "drivers", force: :cascade do |t| + t.string "first_name", null: false + t.string "last_name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "rides", force: :cascade do |t| + t.float "duration", null: false + t.float "distance", null: false + t.float "commute_duration", null: false + t.integer "amount_cents", default: 0, null: false + t.string "amount_currency", default: "USD", null: false + t.bigint "driver_id" + t.bigint "from_address_id", null: false + t.bigint "to_address_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["commute_duration"], name: "index_rides_on_commute_duration" + t.index ["distance"], name: "index_rides_on_distance" + t.index ["driver_id"], name: "index_rides_on_driver_id" + t.index ["duration"], name: "index_rides_on_duration" + t.index ["from_address_id"], name: "index_rides_on_from_address_id" + t.index ["to_address_id"], name: "index_rides_on_to_address_id" + end + + add_foreign_key "driver_addresses", "addresses" + add_foreign_key "driver_addresses", "drivers" + add_foreign_key "rides", "addresses", column: "from_address_id" + add_foreign_key "rides", "addresses", column: "to_address_id" +end diff --git a/spec/factories/addresses.rb b/spec/factories/addresses.rb new file mode 100644 index 0000000..4566130 --- /dev/null +++ b/spec/factories/addresses.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :address do + line_1 { Faker::Address.street_address } + line_2 { nil } + city { Faker::Address.city } + state { Faker::Address.state } + zip_code { Faker::Address.zip_code } + place_id { Faker::Internet.unique.device_token } + latitude { Faker::Address.latitude } + longitude { Faker::Address.longitude } + end +end diff --git a/spec/factories/driver_addresses.rb b/spec/factories/driver_addresses.rb new file mode 100644 index 0000000..a882534 --- /dev/null +++ b/spec/factories/driver_addresses.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :driver_address do + driver + address + end +end diff --git a/spec/factories/drivers.rb b/spec/factories/drivers.rb new file mode 100644 index 0000000..34d7220 --- /dev/null +++ b/spec/factories/drivers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :driver do + first_name { Faker::Name.first_name } + last_name { Faker::Name.last_name } + end +end diff --git a/spec/factories/rides.rb b/spec/factories/rides.rb new file mode 100644 index 0000000..554a575 --- /dev/null +++ b/spec/factories/rides.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ride do + duration { 2.3 } + commute_duration { 1.0 } + distance { 30.1 } + amount_cents { 1200 } + + driver { nil } + + association :from_address, factory: :address + association :to_address, factory: :address + end +end diff --git a/spec/models/address_spec.rb b/spec/models/address_spec.rb new file mode 100644 index 0000000..1cab9ba --- /dev/null +++ b/spec/models/address_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Address, type: :model do + describe "associations" do + it { is_expected.to have_many(:driver_addresses).dependent(:destroy) } + end + + describe "attributes" do + it { is_expected.to validate_presence_of(:line_1) } + it { is_expected.to validate_presence_of(:city) } + it { is_expected.to validate_presence_of(:state) } + it { is_expected.to validate_presence_of(:zip_code) } + it { is_expected.to validate_presence_of(:latitude) } + it { is_expected.to validate_presence_of(:longitude) } + end + + describe "instance_methods" do + it "can query for associated rides" do + ride = create(:ride) + from_address = ride.from_address + to_address = ride.to_address + + expect(from_address.rides).to include(ride) + expect(to_address.rides).to include(ride) + end + end +end diff --git a/spec/models/driver_address_spec.rb b/spec/models/driver_address_spec.rb new file mode 100644 index 0000000..1810f5b --- /dev/null +++ b/spec/models/driver_address_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DriverAddress, type: :model do + subject { create(:driver_address) } + describe "associations" do + it { is_expected.to belong_to(:driver) } + it { is_expected.to belong_to(:address) } + + it do + is_expected.to validate_uniqueness_of(:current) + .scoped_to(:driver_id) + .with_message("can only have one current Driver") + end + end +end diff --git a/spec/models/driver_spec.rb b/spec/models/driver_spec.rb new file mode 100644 index 0000000..349e92b --- /dev/null +++ b/spec/models/driver_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Driver, type: :model do + describe "associations" do + it { is_expected.to have_many(:driver_addresses).dependent(:destroy) } + it { is_expected.to have_one(:current_driver_address).inverse_of(:driver).dependent(:destroy) } + it { is_expected.to have_one(:current_address).through(:current_driver_address).dependent(:destroy) } + end + + describe "attributes" do + it { is_expected.to validate_presence_of(:first_name) } + it { is_expected.to validate_presence_of(:last_name) } + end +end diff --git a/spec/models/ride_spec.rb b/spec/models/ride_spec.rb new file mode 100644 index 0000000..8140476 --- /dev/null +++ b/spec/models/ride_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Ride, type: :model do + subject { create(:ride) } + describe "associations" do + it { is_expected.to belong_to(:driver).optional(true) } + it { is_expected.to belong_to(:from_address).class_name("Address") } + it { is_expected.to belong_to(:to_address).class_name("Address") } + end + + describe "attributes" do + it { is_expected.to validate_presence_of(:duration) } + it { is_expected.to validate_presence_of(:distance) } + it { is_expected.to validate_presence_of(:commute_duration) } + it { is_expected.to validate_presence_of(:amount_cents) } + it { is_expected.to monetize(:amount_cents).as(:amount) } + it { is_expected.to validate_numericality_of(:amount_cents) } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index cc55681..9179db6 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,15 +1,17 @@ +# frozen_string_literal: true + # This file is copied to spec/ when you run 'rails generate rspec:install' -ENV['RAILS_ENV'] ||= 'test' -require_relative '../config/environment' +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" # Prevent database truncation if the environment is production -abort('The Rails environment is running in production mode!') if Rails.env.production? -require 'rspec/rails' -require 'spec_helper' -require 'webmock/rspec' -require 'vcr' +abort("The Rails environment is running in production mode!") if Rails.env.production? +require "rspec/rails" +require "spec_helper" +require "webmock/rspec" +require "vcr" VCR.configure do |config| - config.cassette_library_dir = 'spec/cassettes' + config.cassette_library_dir = "spec/cassettes" config.hook_into :webmock end # Add additional requires below this line. Rails is not loaded until this point! @@ -27,7 +29,7 @@ # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f } +Rails.root.glob("spec/support/**/*.rb").sort.each { |f| require f } # Checks for pending migrations and applies them before tests are run. # If you are not using ActiveRecord, you can remove these lines. @@ -49,7 +51,7 @@ RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = [ - Rails.root.join('spec/fixtures') + Rails.root.join("spec/fixtures") ] # If you're not using ActiveRecord, or you'd prefer not to run each of your diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 19fc493..d835b85 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,7 @@ -require 'rails_helper' +# frozen_string_literal: true + +require "rails_helper" +require "money-rails/test_helpers" # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb index c7890e4..2e7665c 100644 --- a/spec/support/factory_bot.rb +++ b/spec/support/factory_bot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods end