diff --git a/Gemfile b/Gemfile index 889ccab..2032441 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,7 @@ end gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] group :development, :test do + gem 'faker' gem 'rspec-rails', '~> 5.0.0' end @@ -49,3 +50,10 @@ group :test do gem 'faker' gem 'database_cleaner' end + +# Use ActiveModel has_secure_password +gem 'bcrypt', '~> 3.1.7' + +gem 'jwt' + +gem 'active_model_serializers', '~> 0.10.0' diff --git a/Gemfile.lock b/Gemfile.lock index fa76adb..cc7829d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,6 +39,11 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) + active_model_serializers (0.10.12) + actionpack (>= 4.1, < 6.2) + activemodel (>= 4.1, < 6.2) + case_transform (>= 0.2) + jsonapi-renderer (>= 0.1.1.beta1, < 0.3) activejob (6.1.4.1) activesupport (= 6.1.4.1) globalid (>= 0.3.6) @@ -60,10 +65,13 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) + bcrypt (3.1.16) bootsnap (1.9.1) msgpack (~> 1.0) builder (3.2.4) byebug (11.1.3) + case_transform (0.2) + activesupport concurrent-ruby (1.1.9) crass (1.0.6) database_cleaner (2.0.1) @@ -86,6 +94,8 @@ GEM activesupport (>= 5.0) i18n (1.8.11) concurrent-ruby (~> 1.0) + jsonapi-renderer (0.2.2) + jwt (2.3.0) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -178,11 +188,14 @@ PLATFORMS x86_64-linux DEPENDENCIES + active_model_serializers (~> 0.10.0) + bcrypt (~> 3.1.7) bootsnap (>= 1.4.4) byebug database_cleaner factory_bot_rails (~> 4.0) faker + jwt listen (~> 3.3) puma (~> 5.0) rails (~> 6.1.4, >= 6.1.4.1) diff --git a/app/auth/authenticate_user.rb b/app/auth/authenticate_user.rb new file mode 100644 index 0000000..9d84f4a --- /dev/null +++ b/app/auth/authenticate_user.rb @@ -0,0 +1,23 @@ +class AuthenticateUser + def initialize(email, password) + @email = email + @password = password + end + + # Service entry point + def call + JsonWebToken.encode(user_id: user.id) if user + end + + private + + attr_reader :email, :password + + # verify user credentials + def user + user = User.find_by(email: email) + return user if user && user.authenticate(password) + # raise Authentication error if credentials are invalid + raise(ExceptionHandler::AuthenticationError, Message.invalid_credentials) + end +end \ No newline at end of file diff --git a/app/auth/authorize_api_request.rb b/app/auth/authorize_api_request.rb new file mode 100644 index 0000000..afac6eb --- /dev/null +++ b/app/auth/authorize_api_request.rb @@ -0,0 +1,42 @@ +class AuthorizeApiRequest + def initialize(headers = {}) + @headers = headers + end + + # Service entry point - return valid user object + def call + { + user: user + } + end + + private + + attr_reader :headers + + def user + # check if user is in the database + # memoize user object + @user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token + # handle user not found + rescue ActiveRecord::RecordNotFound => e + # raise custom error + raise( + ExceptionHandler::InvalidToken, + ("#{Message.invalid_token} #{e.message}") + ) + end + + # decode authentication token + def decoded_auth_token + @decoded_auth_token ||= JsonWebToken.decode(http_auth_header) + end + + # check for token in `Authorization` header + def http_auth_header + if headers['Authorization'].present? + return headers['Authorization'].split(' ').last + end + raise(ExceptionHandler::MissingToken, Message.missing_token) + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..b245e8e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,15 @@ class ApplicationController < ActionController::API + include Response + include ExceptionHandler + + # called before every action on controllers + before_action :authorize_request + attr_reader :current_user + + private + + # Check for valid request token and return user + def authorize_request + @current_user = (AuthorizeApiRequest.new(request.headers).call)[:user] + end end diff --git a/app/controllers/authentication_controller.rb b/app/controllers/authentication_controller.rb new file mode 100644 index 0000000..c60d702 --- /dev/null +++ b/app/controllers/authentication_controller.rb @@ -0,0 +1,15 @@ +class AuthenticationController < ApplicationController + # return auth token once user is authenticated + skip_before_action :authorize_request, only: :authenticate + def authenticate + auth_token = + AuthenticateUser.new(auth_params[:email], auth_params[:password]).call + json_response(auth_token: auth_token) + end + + private + + def auth_params + params.permit(:email, :password) + end +end diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb new file mode 100644 index 0000000..682e16e --- /dev/null +++ b/app/controllers/concerns/exception_handler.rb @@ -0,0 +1,33 @@ +module ExceptionHandler + # provides the more graceful `included` method + extend ActiveSupport::Concern + + # Define custom error subclasses - rescue catches `StandardErrors` + class AuthenticationError < StandardError; end + class MissingToken < StandardError; end + class InvalidToken < StandardError; end + + included do + # Define custom handlers + rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two + rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request + rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two + rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two + + rescue_from ActiveRecord::RecordNotFound do |e| + json_response({ message: e.message }, :not_found) + end + end + + private + + # JSON response with message; Status code 422 - unprocessable entity + def four_twenty_two(e) + json_response({ message: e.message }, :unprocessable_entity) + end + + # JSON response with message; Status code 401 - Unauthorized + def unauthorized_request(e) + json_response({ message: e.message }, :unauthorized) + end +end diff --git a/app/controllers/concerns/response.rb b/app/controllers/concerns/response.rb new file mode 100644 index 0000000..dc37dca --- /dev/null +++ b/app/controllers/concerns/response.rb @@ -0,0 +1,5 @@ +module Response + def json_response(object, status = :ok) + render json: object, status: status + end +end diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb new file mode 100644 index 0000000..c2eab7a --- /dev/null +++ b/app/controllers/items_controller.rb @@ -0,0 +1,47 @@ +class ItemsController < ApplicationController + before_action :set_todo + before_action :set_todo_item, only: [:show, :update, :destroy] + + # GET /todos/:todo_id/items + def index + json_response(@todo.items) + end + + # GET /todos/:todo_id/items/:id + def show + json_response(@item) + end + + # POST /todos/:todo_id/items + def create + @todo.items.create!(item_params) + json_response(@todo, :created) + end + + # PUT /todos/:todo_id/items/:id + def update + @item.update(item_params) + head :no_content + end + + # DELETE /todos/:todo_id/items/:id + def destroy + @item.destroy + head :no_content + end + + private + + def item_params + params.permit(:name, :done) + end + + def set_todo + @todo = Todo.find(params[:todo_id]) + end + + def set_todo_item + @item = @todo.items.find_by!(id: params[:id]) if @todo + end +end + \ No newline at end of file diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb new file mode 100644 index 0000000..4e7d028 --- /dev/null +++ b/app/controllers/todos_controller.rb @@ -0,0 +1,24 @@ +class TodosController < ApplicationController + # [...] + # GET /todos + def index + # get current user todos + @todos = current_user.todos + json_response(@todos) + end + # [...] + # POST /todos + def create + # create todos belonging to current user + @todo = current_user.todos.create!(todo_params) + json_response(@todo, :created) + end + # [...] + private + + # remove `created_by` from list of permitted parameters + def todo_params + params.permit(:title) + end + # [...] +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..b8b18a7 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,23 @@ + +class UsersController < ApplicationController + # POST /signup + # return authenticated token upon signup + skip_before_action :authorize_request, only: :create + def create + user = User.create!(user_params) + auth_token = AuthenticateUser.new(user.email, user.password).call + response = { message: Message.account_created, auth_token: auth_token } + json_response(response, :created) + end + + private + + def user_params + params.permit( + :name, + :email, + :password, + :password_confirmation + ) + end +end diff --git a/app/lib/json_web_token.rb b/app/lib/json_web_token.rb new file mode 100644 index 0000000..2311a8e --- /dev/null +++ b/app/lib/json_web_token.rb @@ -0,0 +1,21 @@ +class JsonWebToken + # secret to encode and decode token + HMAC_SECRET = Rails.application.secrets.secret_key_base + + def self.encode(payload, exp = 24.hours.from_now) + # set expiry to 24 hours from creation time + payload[:exp] = exp.to_i + # sign token with application secret + JWT.encode(payload, HMAC_SECRET) + end + + def self.decode(token) + # get payload; first index in decoded Array + body = JWT.decode(token, HMAC_SECRET)[0] + HashWithIndifferentAccess.new body + # rescue from all decode errors + rescue JWT::DecodeError => e + # raise custom error to be handled by custom handler + raise ExceptionHandler::InvalidToken, e.message + end +end \ No newline at end of file diff --git a/app/lib/message.rb b/app/lib/message.rb new file mode 100644 index 0000000..736c45f --- /dev/null +++ b/app/lib/message.rb @@ -0,0 +1,33 @@ +class Message + def self.not_found(record = 'record') + "Sorry, #{record} not found." + end + + def self.invalid_credentials + 'Invalid credentials' + end + + def self.invalid_token + 'Invalid token' + end + + def self.missing_token + 'Missing token' + end + + def self.unauthorized + 'Unauthorized request' + end + + def self.account_created + 'Account created successfully' + end + + def self.account_not_created + 'Account could not be created' + end + + def self.expired_token + 'Sorry, your token has expired. Please login to continue.' + end +end \ No newline at end of file diff --git a/app/models/item.rb b/app/models/item.rb new file mode 100644 index 0000000..e33eb18 --- /dev/null +++ b/app/models/item.rb @@ -0,0 +1,7 @@ +class Item < ApplicationRecord + # model association + belongs_to :todo + + # validation + validates_presence_of :name +end diff --git a/app/models/todo.rb b/app/models/todo.rb new file mode 100644 index 0000000..6b6956c --- /dev/null +++ b/app/models/todo.rb @@ -0,0 +1,7 @@ +class Todo < ApplicationRecord + # model association + has_many :items, dependent: :destroy + + # validations + validates_presence_of :title, :created_by +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..ce60935 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,9 @@ +class User < ApplicationRecord + # encrypt password + has_secure_password + + # Model associations + has_many :todos, foreign_key: :created_by + # Validations + validates_presence_of :name, :email, :password_digest +end diff --git a/app/serializers/todo_serializer.rb b/app/serializers/todo_serializer.rb new file mode 100644 index 0000000..dcee3da --- /dev/null +++ b/app/serializers/todo_serializer.rb @@ -0,0 +1,6 @@ +class TodoSerializer < ActiveModel::Serializer + # attributes to be serialized + attributes :id, :title, :created_by, :created_at, :updated_at + # model association + has_many :items +end diff --git a/config/routes.rb b/config/routes.rb index c06383a..af34825 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,9 @@ Rails.application.routes.draw do # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html + resources :todos do + resources :items + end + + post 'auth/login', to: 'authentication#authenticate' + post 'signup', to: 'users#create' end diff --git a/db/migrate/20211110194428_create_todos.rb b/db/migrate/20211110194428_create_todos.rb new file mode 100644 index 0000000..be1f3b1 --- /dev/null +++ b/db/migrate/20211110194428_create_todos.rb @@ -0,0 +1,10 @@ +class CreateTodos < ActiveRecord::Migration[6.1] + def change + create_table :todos do |t| + t.string :title + t.string :created_by + + t.timestamps + end + end +end diff --git a/db/migrate/20211110194855_create_items.rb b/db/migrate/20211110194855_create_items.rb new file mode 100644 index 0000000..53fc718 --- /dev/null +++ b/db/migrate/20211110194855_create_items.rb @@ -0,0 +1,11 @@ +class CreateItems < ActiveRecord::Migration[6.1] + def change + create_table :items do |t| + t.string :name + t.boolean :done + t.references :todo, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20211111154113_create_users.rb b/db/migrate/20211111154113_create_users.rb new file mode 100644 index 0000000..65cbc25 --- /dev/null +++ b/db/migrate/20211111154113_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[6.1] + def change + create_table :users do |t| + t.string :name + t.string :email + t.string :password_digest + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..cf49ca8 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,40 @@ +# 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.define(version: 2021_11_11_154113) do + + create_table "items", force: :cascade do |t| + t.string "name" + t.boolean "done" + t.integer "todo_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["todo_id"], name: "index_items_on_todo_id" + end + + create_table "todos", force: :cascade do |t| + t.string "title" + t.string "created_by" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + create_table "users", force: :cascade do |t| + t.string "name" + t.string "email" + t.string "password_digest" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + add_foreign_key "items", "todos" +end diff --git a/spec/auth/authenticate_user_spec.rb b/spec/auth/authenticate_user_spec.rb new file mode 100644 index 0000000..fda8709 --- /dev/null +++ b/spec/auth/authenticate_user_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe AuthenticateUser do + # create test user + let(:user) { create(:user) } + # valid request subject + subject(:valid_auth_obj) { described_class.new(user.email, user.password) } + # invalid request subject + subject(:invalid_auth_obj) { described_class.new('foo', 'bar') } + + # Test suite for AuthenticateUser#call + describe '#call' do + # return token when valid request + context 'when valid credentials' do + it 'returns an auth token' do + token = valid_auth_obj.call + expect(token).not_to be_nil + end + end + + # raise Authentication Error when invalid request + context 'when invalid credentials' do + it 'raises an authentication error' do + expect { invalid_auth_obj.call } + .to raise_error( + ExceptionHandler::AuthenticationError, + /Invalid credentials/ + ) + end + end + end +end \ No newline at end of file diff --git a/spec/auth/authorize_api_request_spec.rb b/spec/auth/authorize_api_request_spec.rb new file mode 100644 index 0000000..8c5c623 --- /dev/null +++ b/spec/auth/authorize_api_request_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe AuthorizeApiRequest do + # Create test user + let(:user) { create(:user) } + # Mock `Authorization` header + let(:header) { { 'Authorization' => token_generator(user.id) } } + # Invalid request subject + subject(:invalid_request_obj) { described_class.new({}) } + # Valid request subject + subject(:request_obj) { described_class.new(header) } + + # Test Suite for AuthorizeApiRequest#call + # This is our entry point into the service class + describe '#call' do + # returns user object when request is valid + context 'when valid request' do + it 'returns user object' do + result = request_obj.call + expect(result[:user]).to eq(user) + end + end + + # returns error message when invalid request + context 'when invalid request' do + context 'when missing token' do + it 'raises a MissingToken error' do + expect { invalid_request_obj.call } + .to raise_error(ExceptionHandler::MissingToken, 'Missing token') + end + end + + context 'when invalid token' do + subject(:invalid_request_obj) do + # custom helper method `token_generator` + described_class.new('Authorization' => token_generator(5)) + end + + it 'raises an InvalidToken error' do + expect { invalid_request_obj.call } + .to raise_error(ExceptionHandler::InvalidToken, /Invalid token/) + end + end + + context 'when token is expired' do + let(:header) { { 'Authorization' => expired_token_generator(user.id) } } + subject(:request_obj) { described_class.new(header) } + + it 'raises ExceptionHandler::ExpiredSignature error' do + expect { request_obj.call } + .to raise_error( + ExceptionHandler::InvalidToken, + /Signature has expired/ + ) + end + end + + context 'fake token' do + let(:header) { { 'Authorization' => 'foobar' } } + subject(:invalid_request_obj) { described_class.new(header) } + + it 'handles JWT::DecodeError' do + expect { invalid_request_obj.call } + .to raise_error( + ExceptionHandler::InvalidToken, + /Not enough or too many segments/ + ) + end + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000..f1cbe44 --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe ApplicationController, type: :controller do + # create test user + let!(:user) { create(:user) } + # set headers for authorization + let(:headers) { { 'Authorization' => token_generator(user.id) } } + let(:invalid_headers) { { 'Authorization' => nil } } + + describe "#authorize_request" do + context "when auth token is passed" do + before { allow(request).to receive(:headers).and_return(headers) } + + # private method authorize_request returns current user + it "sets the current user" do + expect(subject.instance_eval { authorize_request }).to eq(user) + end + end + + context "when auth token is not passed" do + before do + allow(request).to receive(:headers).and_return(invalid_headers) + end + + it "raises MissingToken error" do + expect { subject.instance_eval { authorize_request } }. + to raise_error(ExceptionHandler::MissingToken, /Missing token/) + end + end + end +end \ No newline at end of file diff --git a/spec/factories/items.rb b/spec/factories/items.rb new file mode 100644 index 0000000..330ff5e --- /dev/null +++ b/spec/factories/items.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :item do + name { Faker::Movies::StarWars.character } + done { false } + todo_id { nil } + end +end diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb new file mode 100644 index 0000000..66bb5cc --- /dev/null +++ b/spec/factories/todos.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :todo do + title { Faker::Lorem.word } + created_by { Faker::Number.number(digits: 10) } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..8a7972c --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +# spec/factories/users.rb +FactoryBot.define do + factory :user do + name { Faker::Name.name } + email { 'foo@bar.com' } + password { 'foobar' } + end +end diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb new file mode 100644 index 0000000..7ecafdf --- /dev/null +++ b/spec/models/item_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Item, type: :model do + # Association test + # ensure an item record belongs to a single todo record + it { should belong_to(:todo) } + # Validation test + # ensure column name is present before saving + it { should validate_presence_of(:name) } +end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb new file mode 100644 index 0000000..be399c4 --- /dev/null +++ b/spec/models/todo_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe Todo, type: :model do + # Association test + # ensure Todo model has a 1:m relationship with the Item model + it { should have_many(:items).dependent(:destroy) } + # Validation tests + # ensure columns title and created_by are present before saving + it { should validate_presence_of(:title) } + it { should validate_presence_of(:created_by) } +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..677e298 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + # Association test + # ensure User model has a 1:m relationship with the Todo model + it { should have_many(:todos) } + # Validation tests + # ensure name, email and password_digest are present before save + it { should validate_presence_of(:name) } + it { should validate_presence_of(:email) } + it { should validate_presence_of(:password_digest) } +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d74f46f..48f179a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,8 @@ # require database cleaner at the top level require 'database_cleaner' +require 'support/request_spec_helper' +require 'support/controller_spec_helper' # [...] # configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails @@ -81,6 +83,9 @@ # add `FactoryBot` methods config.include FactoryBot::Syntax::Methods + config.include RequestSpecHelper + config.include ControllerSpecHelper + # start by truncating all the tables but then use the faster transaction strategy the rest of the time. config.before(:suite) do DatabaseCleaner.clean_with(:truncation) diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb new file mode 100644 index 0000000..7c8d762 --- /dev/null +++ b/spec/requests/authentication_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe 'Authentication', type: :request do + # Authentication test suite + describe 'POST /auth/login' do + # create test user + let!(:user) { create(:user) } + # set headers for authorization + let(:headers) { valid_headers.except('Authorization') } + # set test valid and invalid credentials + let(:valid_credentials) do + { + email: user.email, + password: user.password + }.to_json + end + let(:invalid_credentials) do + { + email: Faker::Internet.email, + password: Faker::Internet.password + }.to_json + end + + # set request.headers to our custon headers + # before { allow(request).to receive(:headers).and_return(headers) } + + # returns auth token when request is valid + context 'When request is valid' do + before { post '/auth/login', params: valid_credentials, headers: headers } + + it 'returns an authentication token' do + expect(json['auth_token']).not_to be_nil + end + end + + # returns failure message when request is invalid + context 'When request is invalid' do + before { post '/auth/login', params: invalid_credentials, headers: headers } + + it 'returns a failure message' do + expect(json['message']).to match(/Invalid credentials/) + end + end + end +end diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb new file mode 100644 index 0000000..65aba7c --- /dev/null +++ b/spec/requests/items_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe 'Items API' do + let(:user) { create(:user) } + let!(:todo) { create(:todo, created_by: user.id) } + let!(:items) { create_list(:item, 20, todo_id: todo.id) } + let(:todo_id) { todo.id } + let(:id) { items.first.id } + let(:headers) { valid_headers } + + describe 'GET /todos/:todo_id/items' do + before { get "/todos/#{todo_id}/items", params: {}, headers: headers } + + # [...] + end + + describe 'GET /todos/:todo_id/items/:id' do + before { get "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers } + + # [...] + end + + describe 'POST /todos/:todo_id/items' do + let(:valid_attributes) { { name: 'Visit Narnia', done: false }.to_json } + + context 'when request attributes are valid' do + before do + post "/todos/#{todo_id}/items", params: valid_attributes, headers: headers + end + + # [...] + end + + context 'when an invalid request' do + before { post "/todos/#{todo_id}/items", params: {}, headers: headers } + + # [...] + end + end + + describe 'PUT /todos/:todo_id/items/:id' do + let(:valid_attributes) { { name: 'Mozart' }.to_json } + + before do + put "/todos/#{todo_id}/items/#{id}", params: valid_attributes, headers: headers + end + + # [...] + # [...] + end + + describe 'DELETE /todos/:id' do + before { delete "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers } + + # [...] + end +end \ No newline at end of file diff --git a/spec/requests/todos_spec.rb b/spec/requests/todos_spec.rb new file mode 100644 index 0000000..0e6fcc6 --- /dev/null +++ b/spec/requests/todos_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe 'Todos API', type: :request do + # add todos owner + let(:user) { create(:user) } + let!(:todos) { create_list(:todo, 10, created_by: user.id) } + let(:todo_id) { todos.first.id } + # authorize request + let(:headers) { valid_headers } + + describe 'GET /todos' do + # update request with headers + before { get '/todos', params: {}, headers: headers } + + # [...] + end + + describe 'GET /todos/:id' do + before { get "/todos/#{todo_id}", params: {}, headers: headers } + # [...] + end + # [...] + end + + describe 'POST /todos' do + let(:valid_attributes) do + # send json payload + { title: 'Learn Elm', created_by: user.id.to_s }.to_json + end + + context 'when request is valid' do + before { post '/todos', params: valid_attributes, headers: headers } + # [...] + end + + context 'when the request is invalid' do + let(:invalid_attributes) { { title: nil }.to_json } + before { post '/todos', params: invalid_attributes, headers: headers } + + it 'returns status code 422' do + expect(response).to have_http_status(422) + end + + # it 'returns a validation failure message' do + # expect(json['message']) + # .to match(/Validation failed: Title can't be blank/) + # end + end + + describe 'PUT /todos/:id' do + let(:valid_attributes) { { title: 'Shopping' }.to_json } + + context 'when the record exists' do + before { put "/todos/#{todo_id}", params: valid_attributes, headers: headers } + # [...] + end + end + + describe 'DELETE /todos/:id' do + before { delete "/todos/#{todo_id}", params: {}, headers: headers } + # [...] + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb new file mode 100644 index 0000000..70b1a09 --- /dev/null +++ b/spec/requests/users_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Users API', type: :request do + let(:user) { build(:user) } + let(:headers) { valid_headers.except('Authorization') } + let(:valid_attributes) do + attributes_for(:user, password_confirmation: user.password) + end + + # User signup test suite + describe 'POST /signup' do + context 'when valid request' do + before { post '/signup', params: valid_attributes.to_json, headers: headers } + + it 'creates a new user' do + expect(response).to have_http_status(201) + end + + it 'returns success message' do + expect(json['message']).to match(/Account created successfully/) + end + + it 'returns an authentication token' do + expect(json['auth_token']).not_to be_nil + end + end + + context 'when invalid request' do + before { post '/signup', params: {}, headers: headers } + + it 'does not create a new user' do + expect(response).to have_http_status(422) + end + + it 'returns failure message' do + expect(json['message']) + .to match(/Validation failed: Password can't be blank, Name can't be blank, Email can't be blank, Password digest can't be blank/) + end + end + end +end \ No newline at end of file diff --git a/spec/support/controller_spec_helper.rb b/spec/support/controller_spec_helper.rb new file mode 100644 index 0000000..eb5b982 --- /dev/null +++ b/spec/support/controller_spec_helper.rb @@ -0,0 +1,27 @@ +module ControllerSpecHelper + # generate tokens from user id + def token_generator(user_id) + JsonWebToken.encode(user_id: user_id) + end + + # generate expired tokens from user id + def expired_token_generator(user_id) + JsonWebToken.encode({ user_id: user_id }, (Time.now.to_i - 10)) + end + + # return valid headers + def valid_headers + { + "Authorization" => token_generator(user.id), + "Content-Type" => "application/json" + } + end + + # return invalid headers + def invalid_headers + { + "Authorization" => nil, + "Content-Type" => "application/json" + } + end +end \ No newline at end of file diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb new file mode 100644 index 0000000..4c2b8ad --- /dev/null +++ b/spec/support/request_spec_helper.rb @@ -0,0 +1,6 @@ +module RequestSpecHelper + # Parse JSON response to ruby hash + def json + JSON.parse(response.body) + end +end