diff --git a/.idea/vereto-api.iml b/.idea/vereto-api.iml
index 0d3104c..640eaee 100644
--- a/.idea/vereto-api.iml
+++ b/.idea/vereto-api.iml
@@ -67,7 +67,6 @@
-
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 4c0a765..40bf048 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -2,14 +2,36 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
@@ -30,8 +52,8 @@
-
-
+
+
@@ -40,38 +62,41 @@
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
@@ -85,7 +110,6 @@
@@ -151,24 +193,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
@@ -309,16 +339,15 @@
1518771982661
-
+
-
+
-
@@ -328,7 +357,7 @@
-
+
@@ -367,14 +396,6 @@
-
-
-
-
-
-
-
-
@@ -439,7 +460,7 @@
-
+
@@ -447,6 +468,14 @@
+
+
+
+
+
+
+
+
@@ -455,15 +484,7 @@
-
-
-
-
-
-
-
-
-
+
@@ -471,26 +492,34 @@
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
@@ -503,26 +532,169 @@
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Gemfile b/Gemfile
index c82a200..2c89434 100644
--- a/Gemfile
+++ b/Gemfile
@@ -17,7 +17,8 @@ gem 'puma', '~> 3.7'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use ActiveModel has_secure_password
-# gem 'bcrypt', '~> 3.1.7'
+gem 'bcrypt', '~> 3.1.7'
+gem 'jwt'
# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development
diff --git a/Gemfile.lock b/Gemfile.lock
index 538f048..80bed93 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -39,6 +39,7 @@ GEM
minitest (~> 5.1)
tzinfo (~> 1.1)
arel (8.0.0)
+ bcrypt (3.1.11)
builder (3.2.3)
byebug (10.0.0)
concurrent-ruby (1.0.5)
@@ -58,6 +59,7 @@ GEM
activesupport (>= 4.2.0)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
+ jwt (2.1.0)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
@@ -150,10 +152,12 @@ PLATFORMS
ruby
DEPENDENCIES
+ bcrypt (~> 3.1.7)
byebug
database_cleaner
factory_girl_rails (~> 4.0)
faker
+ jwt
listen (>= 3.0.5, < 3.2)
mysql2 (>= 0.4.10)
puma (~> 3.7)
diff --git a/app/auth/authenticate_user.rb b/app/auth/authenticate_user.rb
new file mode 100644
index 0000000..20522c0
--- /dev/null
+++ b/app/auth/authenticate_user.rb
@@ -0,0 +1,22 @@
+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
+
+ def user
+ user = User.find_by(email: email)
+ return user if user && user.authenticate(password)
+
+ 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..f16a429
--- /dev/null
+++ b/app/auth/authorize_api_request.rb
@@ -0,0 +1,41 @@
+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 db
+ # memoize user object
+ @user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token
+ # handle user not found
+ rescue ActiveRecord::RecordNotFound => e
+ raise(
+ ExceptionHandler::InvalidToken,
+ ("#{Message.invalid_token} #{e.message}")
+ )
+ end
+
+ # decode auth token
+ def decoded_auth_token
+ @decoded_auth_token ||= JsonWebToken.decode(http_auth_header)
+ end
+
+ 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 ce9cc77..674c0f1 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,14 @@
class ApplicationController < ActionController::API
include Response
include ExceptionHandler
+
+ # called before every action on controllers
+ before_action :authorize_request
+ attr_reader :current_user
+
+ private
+
+ 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..e5cc080
--- /dev/null
+++ b/app/controllers/authentication_controller.rb
@@ -0,0 +1,15 @@
+class AuthenticationController < ApplicationController
+ 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
index 8718061..66a441f 100644
--- a/app/controllers/concerns/exception_handler.rb
+++ b/app/controllers/concerns/exception_handler.rb
@@ -1,14 +1,34 @@
+# I made this file lel
module ExceptionHandler
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
- rescue_from ActiveRecord::RecordInvalid do |e|
- json_response({ message: e.message}, :unprocessable_entity)
- end
+ private
+
+ # JSON Resoinse 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
\ No newline at end of file
diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb
index 5c818d7..d8e3286 100644
--- a/app/controllers/items_controller.rb
+++ b/app/controllers/items_controller.rb
@@ -1,2 +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/:d
+ 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
diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb
index 334d687..204d4bb 100644
--- a/app/controllers/todos_controller.rb
+++ b/app/controllers/todos_controller.rb
@@ -3,13 +3,13 @@ class TodosController < ApplicationController
# GET /todos
def index
- @todos = Todo.all
+ @todos = current_user.todos
json_response(@todos)
end
# POST /todos
def create
- @todo = Todo.create!(todo_params)
+ @todo = current_user.todos.create!(todo_params)
json_response(@todo, :created)
end
@@ -34,7 +34,7 @@ class TodosController < ApplicationController
def todo_params
# whitelist params
- params.permit(:title, :created_by)
+ params.permit(:title)
end
def set_todo
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 0000000..c44c9f0
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,22 @@
+class UsersController < ApplicationController
+ skip_before_action :authorize_request, only: :create
+ # POST /signup
+ # return authenticated token upon signup
+ 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..1b87df3
--- /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..15e5fbc
--- /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/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..6b3058f
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,8 @@
+class User < ApplicationRecord
+ has_secure_password
+
+ has_many :todos, foreign_key: :created_by
+
+ validates_presence_of :name, :email, :password_digest
+ validates_uniqueness_of :email
+end
diff --git a/config/routes.rb b/config/routes.rb
index e8b69a7..1266c8f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,4 +3,7 @@ Rails.application.routes.draw do
resources :todos do
resources :items
end
+
+ post 'auth/login', to: 'authentication#authenticate'
+ post 'signup', to: 'users#create'
end
diff --git a/db/migrate/20180216131546_create_users.rb b/db/migrate/20180216131546_create_users.rb
new file mode 100644
index 0000000..a244bfa
--- /dev/null
+++ b/db/migrate/20180216131546_create_users.rb
@@ -0,0 +1,11 @@
+class CreateUsers < ActiveRecord::Migration[5.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
index 41b62f1..903c0d4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20180216101247) do
+ActiveRecord::Schema.define(version: 20180216131546) do
create_table "items", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
t.string "name"
@@ -28,5 +28,13 @@ ActiveRecord::Schema.define(version: 20180216101247) do
t.datetime "updated_at", null: false
end
+ create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t|
+ t.string "name"
+ t.string "email"
+ t.string "password_digest"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", 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..63bc00a
--- /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..e289539
--- /dev/null
+++ b/spec/auth/authorize_api_request_spec.rb
@@ -0,0 +1,73 @@
+# spec/auth/authorize_api_request_spec.rb
+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/controllers/authentication_controller_spec.rb b/spec/controllers/authentication_controller_spec.rb
new file mode 100644
index 0000000..af11146
--- /dev/null
+++ b/spec/controllers/authentication_controller_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe AuthenticationController, type: :controller do
+
+end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
new file mode 100644
index 0000000..e2c3d3b
--- /dev/null
+++ b/spec/controllers/users_controller_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe UsersController, type: :controller do
+
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 0000000..3c4fc15
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,7 @@
+FactoryGirl.define do
+ factory :user do
+ name { Faker::Name.name }
+ email 'fooooooo@bar.com'
+ password 'foobar'
+ end
+end
\ No newline at end of file
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 0907d03..4aa2a0d 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -44,7 +44,8 @@ RSpec.configure do |config|
config.use_transactional_fixtures = true
# add `FactoryGirl` methods
config.include FactoryGirl::Syntax::Methods
- config.include RequestSpecHelper, type: :request
+ 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..aaaa9a1
--- /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
\ No newline at end of file
diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb
index e69de29..2a12b3f 100644
--- a/spec/requests/items_spec.rb
+++ b/spec/requests/items_spec.rb
@@ -0,0 +1,130 @@
+require 'rails_helper'
+
+RSpec.describe 'Items API' do
+ # Initialize the test data
+ 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 }
+
+ context 'when todo exists' do
+ it 'returns status code 200' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns all todo items' do
+ expect(json.size).to eq(20)
+ end
+ end
+
+ context 'when todo does not exist' do
+ let(:todo_id) { 0 }
+
+ it 'returns status code 404' do
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns a not found message' do
+ expect(response.body).to match(/Couldn't find Todo/)
+ end
+ end
+ end
+
+ # Test suite for GET /todos/:todo_id/items/:id
+ describe 'GET /todos/:todo_id/items/:id' do
+ before { get "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers }
+
+ context 'when todo item exists' do
+ it 'returns status code 200' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns the item' do
+ expect(json['id']).to eq(id)
+ end
+ end
+
+ context 'when todo item does not exist' do
+ let(:id) { 0 }
+
+ it 'returns status code 404' do
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns a not found message' do
+ expect(response.body).to match(/Couldn't find Item/)
+ end
+ end
+ end
+
+ # Test suite for PUT /todos/:todo_id/items
+ 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 { post "/todos/#{todo_id}/items", params: valid_attributes, headers: headers }
+
+ it 'returns status code 201' do
+ expect(response).to have_http_status(201)
+ end
+ end
+
+ context 'when an invalid request' do
+ before { post "/todos/#{todo_id}/items", params: {}, headers: headers }
+
+ it 'returns status code 422' do
+ expect(response).to have_http_status(422)
+ end
+
+ it 'returns a failure message' do
+ expect(response.body).to match(/Validation failed: Name can't be blank/)
+ end
+ end
+ end
+
+ # Test suite for PUT /todos/:todo_id/items/:id
+ 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
+
+ context 'when item exists' do
+ it 'returns status code 204' do
+ expect(response).to have_http_status(204)
+ end
+
+ it 'updates the item' do
+ updated_item = Item.find(id)
+ expect(updated_item.name).to match(/Mozart/)
+ end
+ end
+
+ context 'when the item does not exist' do
+ let(:id) { 0 }
+
+ it 'returns status code 404' do
+ expect(response).to have_http_status(404)
+ end
+
+ it 'returns a not found message' do
+ expect(response.body).to match(/Couldn't find Item/)
+ end
+ end
+ end
+
+ # Test suite for DELETE /todos/:id
+ describe 'DELETE /todos/:id' do
+ before { delete "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers }
+
+ it 'returns status code 204' do
+ expect(response).to have_http_status(204)
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/requests/todos_spec.rb b/spec/requests/todos_spec.rb
index 3eefdba..f22549e 100644
--- a/spec/requests/todos_spec.rb
+++ b/spec/requests/todos_spec.rb
@@ -2,14 +2,17 @@
require 'rails_helper'
RSpec.describe 'Todos API', type: :request do
- # initialize test data
- let!(:todos) { create_list(:todo, 10) }
+ # 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 }
# Test suite for GET /todos
describe 'GET /todos' do
# make HTTP get request before each example
- before { get '/todos' }
+ before { get '/todos', params: {}, headers: headers }
it 'returns todos' do
# Note `json` is a custom helper to parse JSON responses
@@ -24,7 +27,7 @@ RSpec.describe 'Todos API', type: :request do
# Test suite for GET /todos/:id
describe 'GET /todos/:id' do
- before { get "/todos/#{todo_id}" }
+ before { get "/todos/#{todo_id}", params: {}, headers: headers }
context 'when the record exists' do
it 'returns the todo' do
@@ -53,10 +56,13 @@ RSpec.describe 'Todos API', type: :request do
# Test suite for POST /todos
describe 'POST /todos' do
# valid payload
- let(:valid_attributes) { { title: 'Learn Elm', created_by: '1' } }
+ let(:valid_attributes) do
+ # send json payload
+ { title: 'Learn Elm', created_by: user.id.to_s }.to_json
+ end
context 'when the request is valid' do
- before { post '/todos', params: valid_attributes }
+ before { post '/todos', params: valid_attributes, headers: headers }
it 'creates a todo' do
expect(json['title']).to eq('Learn Elm')
@@ -68,25 +74,26 @@ RSpec.describe 'Todos API', type: :request do
end
context 'when the request is invalid' do
- before { post '/todos', params: { title: 'Foobar' } }
+ 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(response.body)
- .to match(/Validation failed: Created by can't be blank/)
+ expect(json['message'])
+ .to match(/Validation failed: Title can't be blank/)
end
end
end
# Test suite for PUT /todos/:id
describe 'PUT /todos/:id' do
- let(:valid_attributes) { { title: 'Shopping' } }
+ let(:valid_attributes) { { title: 'Shopping' }.to_json }
context 'when the record exists' do
- before { put "/todos/#{todo_id}", params: valid_attributes }
+ before { put "/todos/#{todo_id}", params: valid_attributes, headers: headers }
it 'updates the record' do
expect(response.body).to be_empty
@@ -100,7 +107,7 @@ RSpec.describe 'Todos API', type: :request do
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
- before { delete "/todos/#{todo_id}" }
+ before { delete "/todos/#{todo_id}", params: {}, headers: headers }
it 'returns status code 204' do
expect(response).to have_http_status(204)
diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb
new file mode 100644
index 0000000..d19b4e1
--- /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..cfbb894
--- /dev/null
+++ b/spec/support/controller_spec_helper.rb
@@ -0,0 +1,28 @@
+# spec/support/controller_spec_helper.rb
+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