diff --git a/Gemfile b/Gemfile index b465a1b..1d66744 100644 --- a/Gemfile +++ b/Gemfile @@ -2,11 +2,6 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.2.2' -gem 'bootstrap', '~> 5.3' -gem 'cancancan' -gem 'devise' -gem 'rspec-rails' -gem 'rubocop', '>= 1.0', '< 2.0' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem 'rails', '~> 7.0.8' @@ -14,7 +9,7 @@ gem 'rails', '~> 7.0.8' gem 'sprockets-rails' # Use postgresql as the database for Active Record -gem 'pg', '~> 1.1' +gem 'pg', '~> 1.5', '>= 1.5.4' # Use the Puma web server [https://github.com/puma/puma] gem 'puma', '~> 5.0' @@ -46,6 +41,7 @@ gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false +gem 'rubocop', '>= 1.0', '< 2.0' # Use Sass to process CSS # gem "sassc-rails" @@ -60,7 +56,6 @@ end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem 'web-console' - # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] # gem "rack-mini-profiler" @@ -74,4 +69,7 @@ group :test do gem 'selenium-webdriver' end -gem 'cssbundling-rails', '~> 1.3' +gem 'cancancan' +gem 'devise', '~> 4.9' +gem 'factory_bot_rails' +gem 'rspec-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 7509784..2f19169 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -69,17 +69,11 @@ GEM addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) - autoprefixer-rails (10.4.15.0) - execjs (~> 2) base64 (0.1.1) bcrypt (3.1.19) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) - bootstrap (5.3.0) - autoprefixer-rails (>= 9.1.0) - popper_js (>= 2.11.7, < 3) - sassc-rails (>= 2.0.0) builder (3.2.4) cancancan (3.5.0) capybara (3.39.2) @@ -93,8 +87,6 @@ GEM xpath (~> 3.2) concurrent-ruby (1.2.2) crass (1.0.6) - cssbundling-rails (1.3.3) - railties (>= 6.0.0) date (3.3.3) debug (1.8.0) irb (>= 1.5.0) @@ -107,8 +99,11 @@ GEM warden (~> 1.2.3) diff-lcs (1.5.0) erubi (1.12.0) - execjs (2.9.1) - ffi (1.16.3) + factory_bot (6.2.1) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.1) @@ -157,7 +152,6 @@ GEM ast (~> 2.4.1) racc pg (1.5.4) - popper_js (2.11.8) psych (5.1.1) stringio public_suffix (5.0.3) @@ -239,14 +233,6 @@ GEM parser (>= 3.2.1.0) ruby-progressbar (1.13.0) rubyzip (2.3.2) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt selenium-webdriver (4.14.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) @@ -262,7 +248,6 @@ GEM railties (>= 6.0.0) stringio (3.0.8) thor (1.2.2) - tilt (2.3.0) timeout (0.4.0) turbo-rails (1.5.0) actionpack (>= 6.0.0) @@ -292,15 +277,14 @@ PLATFORMS DEPENDENCIES bootsnap - bootstrap (~> 5.3) cancancan capybara - cssbundling-rails (~> 1.3) debug - devise + devise (~> 4.9) + factory_bot_rails importmap-rails jbuilder - pg (~> 1.1) + pg (~> 1.5, >= 1.5.4) puma (~> 5.0) rails (~> 7.0.8) rspec-rails diff --git a/app/controllers/recipe_controller.rb b/app/controllers/recipe_controller.rb index e28047d..e60f784 100644 --- a/app/controllers/recipe_controller.rb +++ b/app/controllers/recipe_controller.rb @@ -1,6 +1,11 @@ class RecipeController < ApplicationController + load_and_authorize_resource def index - @recipes = Recipe.all + @recipes = if user_signed_in? + Recipe.where(public: true).or(Recipe.where(user_id: current_user.id)) + else + Recipe.where(public: true) + end end def new diff --git a/app/models/ability.rb b/app/models/ability.rb new file mode 100644 index 0000000..8e517c7 --- /dev/null +++ b/app/models/ability.rb @@ -0,0 +1,32 @@ +class Ability + include CanCan::Ability + + def initialize(user) + user ||= User.new + can :manage, Recipe, user_id: user.id + # Define abilities for the user here. For example: + # + # return unless user.present? + # can :read, :all + # return unless user.admin? + # can :manage, :all + # + # The first argument to `can` is the action you are giving the user + # permission to do. + # If you pass :manage it will apply to every action. Other common actions + # here are :read, :create, :update and :destroy. + # + # The second argument is the resource the user can perform the action on. + # If you pass :all it will apply to every resource. Otherwise pass a Ruby + # class of the resource. + # + # The third argument is an optional hash of conditions to further filter the + # objects. + # For example, here the user can only update published articles. + # + # can :update, Article, published: true + # + # See the wiki for details: + # https://github.com/CanCanCommunity/cancancan/blob/develop/docs/define_check_abilities.md + end +end diff --git a/app/models/recipe.rb b/app/models/recipe.rb index ba33aef..3a539c3 100644 --- a/app/models/recipe.rb +++ b/app/models/recipe.rb @@ -3,9 +3,11 @@ class Recipe < ApplicationRecord has_many :recipe_foods has_many :foods, through: :recipe_foods - validates :name, presence: true - validates :preparation_time, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :cooking_time, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :description, presence: true + validates :name, presence: { message: 'Cannot be empty' } + validates :preparation_time, presence: { message: 'Cannot be empty' }, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :cooking_time, presence: { message: 'Cannot be empty' }, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :description, presence: { message: 'Cannot be empty' } validates :public, inclusion: { in: [true, false] } end diff --git a/app/views/recipe/index.html.erb b/app/views/recipe/index.html.erb index 0e2a5b6..ec899ec 100644 --- a/app/views/recipe/index.html.erb +++ b/app/views/recipe/index.html.erb @@ -8,7 +8,10 @@
  • <%= link_to recipe.name, recipe_path(recipe) %>

    - <%= button_to "Remove", recipe, method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-danger" %> + <% if can? :manage, recipe %> + <%= button_to "Remove", recipe, method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-danger" %> +<% end %> +

    <%= recipe.description %>

    diff --git a/app/views/recipe/new.html.erb b/app/views/recipe/new.html.erb index 301c45a..a5080d4 100644 --- a/app/views/recipe/new.html.erb +++ b/app/views/recipe/new.html.erb @@ -1,28 +1,32 @@ -

    Create a New Recipe

    <%= form_for @recipe, url: new_recipe_path, method: :post do |f| %>
    <%= f.label :name %> <%= f.text_field :name %> + <%= f.object.errors.full_messages_for(:name).join(', ') if f.object.errors.include?(:name) %>
    <%= f.label :preparation_time %> <%= f.number_field :preparation_time %> + <%= f.object.errors.full_messages_for(:preparation_time).join(', ') if f.object.errors.include?(:preparation_time) %>
    <%= f.label :cooking_time %> <%= f.number_field :cooking_time %> + <%= f.object.errors.full_messages_for(:cooking_time).join(', ') if f.object.errors.include?(:cooking_time) %>
    <%= f.label :description %> <%= f.text_area :description %> + <%= f.object.errors.full_messages_for(:description).join(', ') if f.object.errors.include?(:description) %>
    <%= f.label :public %> <%= f.check_box :public %> + <%= f.object.errors.full_messages_for(:public).join(', ') if f.object.errors.include?(:public) %>
    diff --git a/app/views/recipe_foods/index.html.erb b/app/views/recipe_foods/index.html.erb deleted file mode 100644 index 57cb460..0000000 --- a/app/views/recipe_foods/index.html.erb +++ /dev/null @@ -1 +0,0 @@ -

    Recipe Food

    \ No newline at end of file diff --git a/app/views/recipe_foods/show.html.erb b/app/views/recipe_foods/show.html.erb deleted file mode 100644 index e69de29..0000000 diff --git a/spec/features/public_recipes_spec.rb b/spec/features/public_recipes_spec.rb new file mode 100644 index 0000000..1259690 --- /dev/null +++ b/spec/features/public_recipes_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.feature 'Public Recipes Page', type: :feature do + scenario 'all users can see public recipes' do + user = create(:user) + login_as(user, scope: :user) + recipe = create(:recipe, user:) + visit public_recipes_path + expect(page).to have_content(recipe.name) + end +end diff --git a/spec/features/recipe_index_spec.rb b/spec/features/recipe_index_spec.rb new file mode 100644 index 0000000..b3b1cc3 --- /dev/null +++ b/spec/features/recipe_index_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.feature 'Recipe#index Page', type: :feature do + scenario 'authorized user can add a new recipe' do + user = create(:user) + login_as(user, scope: :user) + visit recipe_index_path + click_button('Add Recipe') + expect(page).to have_current_path(new_recipe_path) + end + + scenario 'user can remove their recipe' do + user = create(:user) + login_as(user, scope: :user) + visit recipe_index_path + expect(page).not_to have_button('Remove', exact: true) + end + + scenario 'user can add a new recipe' do + user = create(:user) + login_as(user, scope: :user) + visit recipe_index_path + expect(page).to have_button('Add Recipe', exact: true) + end + + scenario 'user can view their own private and public recipes' do + user = create(:user) + login_as(user, scope: :user) + recipe = create(:recipe, user:) + visit recipe_index_path + expect(page).to have_content(recipe.name) + end +end diff --git a/spec/models/recipe_spec.rb b/spec/models/recipe_spec.rb index 63f198b..d249950 100644 --- a/spec/models/recipe_spec.rb +++ b/spec/models/recipe_spec.rb @@ -1 +1,59 @@ require 'rails_helper' + +RSpec.describe Recipe, type: :model do + describe Recipe, type: :model do + it 'is valid with valid attributes' do + user = User.create(name: 'johndoes', email: 'john@email.com', password: '123456') + recipe = Recipe.new( + name: 'Sample Recipe', + preparation_time: 30, + cooking_time: 60, + description: 'Recipe description', + public: true, + user: + ) + expect(recipe).to be_valid + end + + it 'is not valid without a name' do + recipe = Recipe.new(name: nil) + expect(recipe).not_to be_valid + end + + it 'is not valid without a user' do + recipe = Recipe.new( + name: 'MyRecipe', + preparation_time: 30, + cooking_time: 60, + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + public: true + ) + expect(recipe).not_to be_valid + end + it 'is not valid with a negative preparation time' do + user = User.create(name: 'johndoes', email: 'john@email.com', password: '123456') + recipe = Recipe.new( + name: 'Sample Recipe', + preparation_time: -5, + cooking_time: 60, + description: 'Recipe description', + public: true, + user: + ) + expect(recipe).not_to be_valid + end + + it 'is not valid with a negative cooking time' do + user = User.create(name: 'johndoes', email: 'john@email.com', password: '123456') + recipe = Recipe.new( + name: 'Sample Recipe', + preparation_time: 30, + cooking_time: -10, + description: 'Recipe description', + public: true, + user: + ) + expect(recipe).not_to be_valid + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6584c98..5c5847b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -4,7 +4,13 @@ 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? +Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } require 'rspec/rails' +require 'capybara/rails' +require 'capybara/rspec' +require 'factory_bot_rails' +require 'devise' + # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -30,8 +36,16 @@ abort e.to_s.strip end RSpec.configure do |config| + config.before(:each, type: :feature) do + default_url_options[:host] = 'http://127.0.0.1:3000/' # Replace with your application's host + end + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{Rails.root}/spec/fixtures" + config.include FactoryBot::Syntax::Methods + config.include Devise::Test::IntegrationHelpers, type: :request + config.include Devise::Test::IntegrationHelpers, type: :feature + config.include Devise::Test::ControllerHelpers, type: :controller # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false @@ -61,3 +75,5 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end + +Capybara.javascript_driver = :selenium_chrome_headless diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 0000000..b2d5a13 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,45 @@ +FactoryBot.define do + factory :recipe_food do + quantity { 500 } + end +end + +FactoryBot.define do + factory :user do + name { 'Riley' } + email { 'riley@example.com' } + password { 'password123' } + end +end + +FactoryBot.define do + factory :food do + name { 'Broccoli' } + measurement_unit { 'g' } + price { 0.7 } + quantity { 1000 } + user + end +end + +FactoryBot.define do + factory :recipe do + name { 'Vegetable Soup' } + preparation_time { 10 } + cooking_time { 20 } + description { 'A delicious vegetable soup' } + public { true } + user + end +end + +FactoryBot.define do + factory :public_recipe, class: 'Recipe' do + name { 'Public Recipe' } + preparation_time { 15 } + cooking_time { 25 } + description { 'A public recipe' } + public { true } + user + end +end