diff --git a/Gemfile b/Gemfile index a1f4a87..219ea31 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ gem "importmap-rails" gem "turbo-rails" gem "stimulus-rails" gem "jbuilder" -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" gem "tzinfo-data", platforms: %i[ windows jruby ] gem "bootsnap", require: false # gem "image_processing", "~> 1.2" diff --git a/Gemfile.lock b/Gemfile.lock index ceca887..457965a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,6 +78,7 @@ GEM addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) base64 (0.2.0) + bcrypt (3.1.20) bigdecimal (3.1.7) bindex (0.8.1) bootsnap (1.18.3) @@ -250,6 +251,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap capybara debug diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..910f458 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,31 @@ +class User < ApplicationRecord + has_secure_password + generates_token_for :password_reset, expires_in: 4.hours do + password_salt.last(10) # Invalidates when password changed + end + + validates :username, + presence: true, + uniqueness: true, + length: { minimum: 3, maximum: 20 } + normalizes :username, with: ->(username) { username.strip.downcase } + validates :email, + presence: true, + uniqueness: true, + length: { minimum: 5, maximum: 100 }, + format: { with: URI::MailTo::EMAIL_REGEXP, + message: "must be a valid email address" } + normalizes :email, with: ->(email) { email.strip.downcase } + validates :first_name, + presence: true, + length: { maximum: 50 } + validates :last_name, + allow_nil: false, + length: { maximum: 50 } + + def full_name + return first_name if last_name.blank? + + "#{first_name} #{last_name}" + end +end diff --git a/bin/setup b/bin/setup index 3cd5a9d..4722ccd 100755 --- a/bin/setup +++ b/bin/setup @@ -9,22 +9,16 @@ def system!(*args) end FileUtils.chdir APP_ROOT do - # This script is a way to set up or update your development environment automatically. - # This script is idempotent, so that you can run it at any time and get an expectable outcome. - # Add necessary setup steps to this file. - puts "== Installing dependencies ==" system! "gem install bundler --conservative" system("bundle check") || system!("bundle install") - # puts "\n== Copying sample files ==" - # unless File.exist?("config/database.yml") - # FileUtils.cp "config/database.yml.sample", "config/database.yml" - # end - puts "\n== Preparing database ==" system! "bin/rails db:prepare" + puts "\n== Loading database fixtures ==" + system! "bin/rails db:fixtures:load" + puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" diff --git a/db/migrate/20240413152553_create_users.rb b/db/migrate/20240413152553_create_users.rb new file mode 100644 index 0000000..e89502a --- /dev/null +++ b/db/migrate/20240413152553_create_users.rb @@ -0,0 +1,23 @@ +class CreateUsers < ActiveRecord::Migration[7.1] + def change + create_table :users do |t| + t.string :username, null: false, limit: 20 + t.string :password_digest, null: false, limit: 200 + t.string :email, null: false, limit: 100 + t.string :first_name, null: false, limit: 50 + t.string :last_name, null: false, limit: 50, default: "" + + t.timestamps + end + + add_index :users, :username, unique: true + add_index :users, :email, unique: true + + add_check_constraint :users, "length(username) >= 3", + name: "chk_username_min_length" + add_check_constraint :users, "length(email) >= 5", + name: "chk_email_min_length" + add_check_constraint :users, "length(first_name) >= 1", + name: "chk_first_name_min_length" + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..aca4968 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,32 @@ +# 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_04_13_152553) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "users", force: :cascade do |t| + t.string "username", limit: 20, null: false + t.string "password_digest", limit: 200, null: false + t.string "email", limit: 100, null: false + t.string "first_name", limit: 50, null: false + t.string "last_name", limit: 50, default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["username"], name: "index_users_on_username", unique: true + t.check_constraint "length(email::text) >= 5", name: "chk_email_min_length" + t.check_constraint "length(first_name::text) >= 1", name: "chk_first_name_min_length" + t.check_constraint "length(username::text) >= 3", name: "chk_username_min_length" + end + +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..5287208 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,15 @@ +trevor: + username: tsv + password_digest: <%= BCrypt::Password.create('password', cost: 5) %> + email: trevor@example.com + first_name: Trevor + last_name: Vallender + +<% 1.upto(10) do |i| %> +user_<%= i %>: + username: <%= "user_#{i}" %> + password_digest: <%= BCrypt::Password.create('password', cost: 5) %> + email: <%= "user_#{i}@example.com" %> + first_name: <%= "User#{i}" %> + last_name: <%= "User#{i}" %> +<% end %> diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..4e932c9 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "full name returns first and last name" do + assert_equal "Trevor Vallender", users(:trevor).full_name + end + + test "username must exist" do + assert_must_exist(users(:trevor), "username") + end + + test "username must be unique" do + user1 = User.first + user2 = User.second + user1.username = user2.username + assert_not user1.valid? + end + + test "email must exist" do + assert_must_exist(users(:trevor), "email") + end + + test "email must be unique" do + user1 = User.first + user2 = User.second + user1.email = user2.email + assert_not user1.valid? + end + + test "email must be a valid email" do + user = users(:trevor) + user.email = "trevor" + assert_not user.valid? + assert_includes user.errors[:email], "must be a valid email address" + end + + test "first name must exist" do + assert_must_exist(users(:trevor), "first_name") + end + + test "password reset token is invalid after password changed" do + user = users(:trevor) + token = user.generate_token_for(:password_reset) + assert_equal user, User.find_by_token_for(:password_reset, token) + user.update(password: "new_password") + assert_nil User.find_by_token_for(:password_reset, token) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..0e99c76 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,12 @@ module ActiveSupport # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + def assert_must_exist(record, field) + assert record.valid? + record.attributes = { "#{field}": nil } + assert_not record.valid? + assert_includes record.errors[field.to_sym], "can't be blank" + assert_raises { record.save(validate: false) } + end end end