From 4f6be3d76eab7ea7cc34ce1ce349a9966d42be89 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 10:57:28 +0000 Subject: [PATCH 01/52] chore: Switch from jbuilder to jsonapi-serializer This change came about after a number of research. I found that JSON:API standard follows best practices and it's something I want to become very good at. Also, based on the benchmark tests, it is way faster than jbuilder and the alternatives I found. --- Gemfile | 5 +++-- Gemfile.lock | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index e9aae73..2b638ea 100644 --- a/Gemfile +++ b/Gemfile @@ -8,8 +8,6 @@ gem 'rails', '~> 7.2.1' gem 'pg', '~> 1.1' # Use the Puma web server [https://github.com/puma/puma] gem 'puma', '>= 5.0' -# Build JSON APIs with ease [https://github.com/rails/jbuilder] -gem 'jbuilder' # Use Redis adapter to run Action Cable in production # gem "redis", ">= 4.0.1" @@ -52,3 +50,6 @@ gem 'uuid7', '~> 0.2.0' # for pagination gem 'kaminari' + +# for JSON response serialization +gem 'jsonapi-serializer' diff --git a/Gemfile.lock b/Gemfile.lock index 1b34fa2..6131a00 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,10 +106,9 @@ GEM irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.12.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) json (2.7.2) + jsonapi-serializer (2.2.0) + activesupport (>= 4.2) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -288,7 +287,7 @@ DEPENDENCIES debug dotenv-rails factory_bot_rails - jbuilder + jsonapi-serializer kaminari pg (~> 1.1) puma (>= 5.0) From 96085c44633f033cebb03f19ebea1d4ac2a2dc56 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:01:00 +0000 Subject: [PATCH 02/52] feat: Update schema to make category_id optional on products This update makes the `category_id` on the `products` to be optional so that it doesn't need to be provided when a product does not fit into a category yet. It also ensures that when a category is deleted, the products that depend will have their `category_id` field set to null. Finally, `available` and `currency` have been introduced to the `products` to table to help know the availability status of a product and the currency it uses. --- app/models/product.rb | 7 ++++--- .../20240908094329_change_category_id_products.rb | 7 +++++++ ...908094558_add_available_and_currency_to_products.rb | 10 ++++++++++ db/schema.rb | 6 ++++-- 4 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20240908094329_change_category_id_products.rb create mode 100644 db/migrate/20240908094558_add_available_and_currency_to_products.rb diff --git a/app/models/product.rb b/app/models/product.rb index c9b4a39..5abe3d8 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -4,10 +4,11 @@ class Product < ApplicationRecord include WordCountValidatable - belongs_to :category + belongs_to :category, optional: true - validates :developer_id, :name, :category_id, :price, :user_id, - :stock_quantity, :description, presence: true + validates :developer_id, :name, :price, :user_id, + :stock_quantity, :description, :currency, presence: true + validates :available, inclusion: { in: [true, false] } validate :price_must_be_numeric validates :stock_quantity, numericality: { only_integer: true } diff --git a/db/migrate/20240908094329_change_category_id_products.rb b/db/migrate/20240908094329_change_category_id_products.rb new file mode 100644 index 0000000..b4d45cc --- /dev/null +++ b/db/migrate/20240908094329_change_category_id_products.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ChangeCategoryIdProducts < ActiveRecord::Migration[7.2] + def change + change_column_null :products, :category_id, true + end +end diff --git a/db/migrate/20240908094558_add_available_and_currency_to_products.rb b/db/migrate/20240908094558_add_available_and_currency_to_products.rb new file mode 100644 index 0000000..192c5b8 --- /dev/null +++ b/db/migrate/20240908094558_add_available_and_currency_to_products.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddAvailableAndCurrencyToProducts < ActiveRecord::Migration[7.2] + def change + change_table :products, bulk: true do |t| + t.boolean :available, default: true, null: false + t.string :currency, default: 'USD', null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 96beb07..8026eab 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 20_240_907_131_847) do +ActiveRecord::Schema[7.2].define(version: 20_240_908_094_558) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -35,11 +35,13 @@ t.text "description" t.decimal "price" t.integer "stock_quantity" - t.uuid "category_id", null: false + t.uuid "category_id" t.uuid "user_id" t.uuid "developer_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "available", default: true, null: false + t.string "currency", default: "USD", null: false t.index ["category_id"], name: "index_products_on_category_id" t.index %w[name developer_id user_id], name: "index_products_on_name_and_developer_id_and_user_id", unique: true From 4f0374f243c0146a740cb551b6d9beb0725ff978 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:05:16 +0000 Subject: [PATCH 03/52] test: Add tests for updates and deletion for products and category models --- spec/models/category_spec.rb | 150 +++++++++++++++++++++++++++++++++++ spec/models/product_spec.rb | 120 ++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 06c55d0..67c6f4c 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -134,4 +134,154 @@ expect(Category.count).to eq(2) end end + + describe 'Update category' do + let!(:developer_id) { UUID7.generate } + + let!(:home_appliance) do + Category.create!(name: 'Home Appliances', + description: 'Home appliance products category', + developer_id:) + end + + let!(:computer_accessories) do + Category.create!( + name: 'Computer Accessories', + description: 'Category for everything peripherals to a computer', + developer_id: + ) + end + + it 'allows for updates when the correct data is provided' do + home_appliance.update!(name: 'Updated Home appliance') + expect(home_appliance.reload.name).to eq('Updated Home appliance') + end + + it 'rejects when the name of the category is not set' do + expect do + home_appliance.update!(name: nil) + end.to raise_error(ActiveRecord::RecordInvalid) + + expect(home_appliance.reload.name).to eq('Home Appliances') + end + + it 'rejects updates when the new name maps to an existing record' do + expect do + computer_accessories.update!(name: 'Home Appliances') + end.to raise_error(ActiveRecord::RecordNotUnique) + + expect(computer_accessories.reload.name).to eq('Computer Accessories') + end + end + + describe 'Delete category' do + let!(:developer_id) { UUID7.generate } + + let!(:home_appliance) do + Category.create!(name: 'Home Appliances', + description: 'Home appliance products category', + developer_id:) + end + + it 'deletes the category when provided the right ID' do + expect(Category.count).to eq(1) + + Category.destroy(home_appliance.id) + + expect(Category.count).to eq(0) + end + + it 'can delete on the instance' do + expect(Category.count).to eq(1) + + home_appliance.destroy + + expect(Category.count).to eq(0) + end + + it 'nullifies category_id field on dependent products' do + expect do + Product.create!( + name: 'Binatone 3 in 1 Blender', developer_id:, + category_id: home_appliance.id, price: 100, user_id: UUID7.generate, + stock_quantity: 10, + description: 'This is an amazing blender for all your ' \ + 'cooking needs in household.' + ) + end.to_not raise_error + + product = Product.first + + # save the home appliance for later verifications + category_id = home_appliance.id + + expect(product.category_id).to eq(home_appliance.id) + + home_appliance.destroy! + + expect(home_appliance.destroyed?).to eq(true) + + expect do + Category.find(category_id) + end.to raise_error(ActiveRecord::RecordNotFound) + + # ensure the category_id was nullified after the category is deleted + expect(product.reload.category_id).to eq(nil) + end + + it 'fails when an invalid ID is provided' do + expect do + Category.destroy(UUID7.generate) + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + + # let's work on testing the updates + describe 'Update category' do + let!(:developer_id) { UUID7.generate } + + let!(:home_appliance) do + Category.create!(name: 'Home Appliances', + description: 'Home appliance products category', + developer_id:) + end + + let!(:computer_accessories) do + Category.create!( + name: 'Computer Accessories', + description: 'Category for everything peripherals to a computer', + developer_id: + ) + end + + it 'allows for updates when the correct data is provided' do + home_appliance.update!(name: 'Updated Home appliance') + expect(home_appliance.reload.name).to eq('Updated Home appliance') + expect do + home_appliance.update(name: 'Updated Home appliance').to change + end + end + + it 'changes the updated_at field on update' do + expect do + home_appliance.update(name: 'Updated Home appliance') + end.to(change { home_appliance.updated_at }) + end + + it 'rejects when the name of the category is not set' do + expect do + home_appliance.update!(name: nil) + end.to raise_error(ActiveRecord::RecordInvalid) + + expect(home_appliance.reload.name).to eq('Home Appliances') + end + + it 'rejects updates when the new name maps to an existing record' do + expect do + computer_accessories.update!(name: 'Home Appliances') + end.to raise_error(ActiveRecord::RecordNotUnique) + + expect(computer_accessories.reload.name).to eq('Computer Accessories') + end + end end diff --git a/spec/models/product_spec.rb b/spec/models/product_spec.rb index 0c415bf..b1fda64 100644 --- a/spec/models/product_spec.rb +++ b/spec/models/product_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' + RSpec.describe Product, type: :model do let(:developer_id) { UUID7.generate } let(:user_id) { UUID7.generate } @@ -135,4 +136,123 @@ expect(category.products).to eq([Product.first, Product.last]) end end + + describe 'Deletions' do + # create a home appliance product + let!(:product) do + Product.create!(name: 'Washing Machine', developer_id:, + category_id: category.id, + price: 100, user_id:, + stock_quantity: 10, + description: 'A washing machine for user needs' * 4) + end + + it 'allows deletion of a product' do + expect { product.destroy }.to change(Product, :count).by(-1) + end + + it 'removes the product from the category' do + expect { product.destroy }.to change { category.products.count }.by(-1) + end + + it 'does not allow deletion of a product by a different user' do + different_user = UUID7.generate + expect do + Product.destroy_by(user_id: different_user, developer_id:) + end.not_to change(Product, :count) + end + + it 'allows deletion of a product by the same user' do + expect do + Product.destroy_by(user_id:, developer_id:) + end.to change(Product, :count).by(-1) + end + end + + describe 'Updating a product' do + let(:product) do + Product.create!( + name: 'Laptop cases', developer_id:, + category_id: category.id, + price: 100, user_id:, stock_quantity: 10, + description: 'A case for laptops' * 3 + ) + end + + it 'updates the name successfully' do + expect do + product.update(name: 'Updated Laptop cases') + end.to change { product.name } + .from('Laptop cases').to('Updated Laptop cases') + end + + it 'fails to update with an invalid name' do + expect do + product.update!(name: '') + end.to raise_error(ActiveRecord::RecordInvalid) + + expect(product.reload.name).to eq('Laptop cases') + end + + it 'updates the price successfully' do + expect do + product.update(price: 150) + end.to change { product.price }.from(100).to(150) + end + + it 'fails to update with a non-numeric price' do + expect do + product.update!(price: 'one hundred fifty') + end.to raise_error(ActiveRecord::RecordInvalid) + expect(product.reload.price).to eq(100) + end + + it 'updates the stock quantity successfully' do + expect do + product.update(stock_quantity: 20) + end.to change { product.stock_quantity }.from(10).to(20) + end + + it 'fails to update with a non-integer stock quantity' do + expect do + product.update!(stock_quantity: 'twenty') + end.to raise_error(ActiveRecord::RecordInvalid) + + expect(product.reload.stock_quantity).to eq(10) + end + + it 'updates the description successfully' do + product.update( + description: 'Updated description for laptops and it is also ten words' + ) + expect(product.reload.description).to eq( + 'Updated description for laptops and it is also ten words' + ) + end + + it 'fails to update with a short description' do + expect do + product.update!(description: 'Short') + end.to raise_error(ActiveRecord::RecordInvalid) + expect(product.reload.description).to eq('A case for laptops' * 3) + end + + it 'updates the available status successfully' do + expect do + product.update(available: false) + end.to change { product.available }.from(true).to(false) + end + + it 'updates the currency successfully' do + product.update(currency: 'EUR') + expect(product.reload.currency).to eq('EUR') + end + + it 'fails to update with an invalid currency' do + expect do + product.update!(currency: '') + end.to raise_error(ActiveRecord::RecordInvalid) + expect(product.reload.currency).to eq('USD') + end + end end From 79e41bb0ebe5cec4134f4478aa09a849c8c56b6b Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:17:43 +0000 Subject: [PATCH 04/52] chore: Add default URLs to test and development environments --- config/environments/development.rb | 2 ++ config/environments/test.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/environments/development.rb b/config/environments/development.rb index 0b37b1d..4740f4f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -74,4 +74,6 @@ # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + Rails.application.routes.default_url_options[:host] = 'localhost:3000' end diff --git a/config/environments/test.rb b/config/environments/test.rb index d2d8a47..fe264ee 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -66,4 +66,6 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + Rails.application.routes.default_url_options[:host] = 'test-server.com' end From c564fe40824ac54890ac82b843e3a1db6bd7ea77 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:37:37 +0000 Subject: [PATCH 05/52] feat: Add helper to retrieve response body --- spec/requests_helper.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 spec/requests_helper.rb diff --git a/spec/requests_helper.rb b/spec/requests_helper.rb new file mode 100644 index 0000000..39e7194 --- /dev/null +++ b/spec/requests_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RequestsHelper + def response_body + JSON.parse(response.body, symbolize_names: true) + end +end From 6fb3e6dce704157721290190b25d0a92e84f7192 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:38:09 +0000 Subject: [PATCH 06/52] test: Add test for controller routing to endpoints --- .../routing/api/v1/categories_routing_spec.rb | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 spec/routing/api/v1/categories_routing_spec.rb diff --git a/spec/routing/api/v1/categories_routing_spec.rb b/spec/routing/api/v1/categories_routing_spec.rb new file mode 100644 index 0000000..a01db0c --- /dev/null +++ b/spec/routing/api/v1/categories_routing_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::CategoriesController, type: :routing do + let(:developer_id) { UUID7.generate } + + describe "routing" do + it "routes to #index" do + expect(get: "/api/v1/categories").to route_to("api/v1/categories#index") + end + + it "routes to #show" do + expect(get: "/api/v1/categories/#{developer_id}").to route_to( + "api/v1/categories#show", + id: developer_id + ) + end + + it "routes to #create" do + expect(post: "/api/v1/categories").to route_to("api/v1/categories#create") + end + + it "routes to #update via PUT" do + expect(put: "/api/v1/categories/#{developer_id}").to route_to( + "api/v1/categories#update", id: developer_id + ) + end + + it "routes to #update via PATCH" do + expect(patch: "/api/v1/categories/#{developer_id}").to route_to( + "api/v1/categories#update", id: developer_id + ) + end + + it "routes to #destroy" do + expect(delete: "/api/v1/categories/#{developer_id}").to route_to( + "api/v1/categories#destroy", id: developer_id + ) + end + end +end From d3aa6c061406b4df4232f673e5be5d56e7f56e51 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:45:48 +0000 Subject: [PATCH 07/52] chore: Include RequestsHelper in spec_helper.rb file --- spec/spec_helper.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 409c64b..a83b63b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'requests_helper' + # 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`. # The generated `.rspec` file contains `--require spec_helper` which will cause @@ -91,4 +93,5 @@ # # test failures related to randomization by passing the same `--seed` value # # as the one that triggered the failure. # Kernel.srand config.seed + config.include RequestsHelper, type: :request end From bc9df7d8477be435020b5cffdc0d8e71f59f2d37 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:46:54 +0000 Subject: [PATCH 08/52] feat: Add routes for version 1 of categories and unmatched endpoints --- config/routes.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index d55c305..4be9428 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true Rails.application.routes.draw do + namespace :api do + namespace :v1 do + resources :categories, only: %i[index show create update destroy] + end + end + # Define your application routes per the DSL in # https://guides.rubyonrails.org/routing.html @@ -10,6 +16,5 @@ # app is live. get 'up' => 'rails/health#show', as: :rails_health_check - # Defines the root path route ("/") - # root "posts#index" + match '*unmatched', to: 'application#invalid_route', via: :all end From fe94d7a7d0d5dc7478b81f77fdbc45abf286c234 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:47:43 +0000 Subject: [PATCH 09/52] feat: Add error handling and JsonResponse helper for API responses --- app/controllers/application_controller.rb | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 13c271f..1e75f48 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,42 @@ # frozen_string_literal: true +# Base controller for API controllers class ApplicationController < ActionController::API + include JsonResponse + + rescue_from ActiveRecord::RecordNotFound, with: :object_not_found + rescue_from ActiveRecord::RecordNotUnique, with: :duplicate_object + rescue_from NoMethodError, NameError, with: :internal_server_error + rescue_from ActionController::RoutingError, with: :invalid_route + + def render_error(message: 'An error occurred', status: :bad_request, + details: nil) + numeric_status_code = Rack::Utils.status_code(status) + + render json: { message:, status_code: numeric_status_code, + success: false, details: }, + status: numeric_status_code + end + + def invalid_route + render_error(message: 'Route not found', + details: { path: request.path, + method: request.method }, status: :not_found) + end + + private + + # Handles exceptions raised when database objects are not found + def object_not_found(error) + render_error(details: error.message, status: :not_found) + end + + def duplicate_object(error) + render_error(details: error.message, status: :conflict) + end + + def internal_server_error(_error) + render_error(message: 'Internal Server Error', + status: :internal_server_error) + end end From 153a47284816f07ccc2d2dc0f03c1e67b2433dfc Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:11:06 +0000 Subject: [PATCH 10/52] chore: Add coverage for tests --- Gemfile | 3 +++ Gemfile.lock | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/Gemfile b/Gemfile index 2b638ea..c6a43fd 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,9 @@ group :development, :test do gem 'factory_bot_rails' gem 'dotenv-rails' + + # code coverage + gem 'simplecov', require: false end gem 'uuid7', '~> 0.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 6131a00..6fae23e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -87,6 +87,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.5.1) + docile (1.4.1) dotenv (3.1.2) dotenv-rails (3.1.2) dotenv (= 3.1.2) @@ -258,6 +259,12 @@ GEM rubocop-rails ruby-progressbar (1.13.0) securerandom (0.3.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) stringio (3.1.1) thor (1.3.2) timeout (0.4.1) @@ -294,6 +301,7 @@ DEPENDENCIES rails (~> 7.2.1) rspec-rails (~> 7.0) rubocop-rails-omakase + simplecov tzinfo-data uuid7 (~> 0.2.0) From 9ee311fdc2512eb866681c7012fe49e6bbccef69 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:11:46 +0000 Subject: [PATCH 11/52] chore: Ignore coverage directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d4039be..b3bae88 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ # Ignore master key for decrypting credentials and more. /config/master.key .idea/ +/coverage/ From bddb3368459cbeb9e25659b7c0221f77993b1a5a Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:13:22 +0000 Subject: [PATCH 12/52] chore: Add 'simplecov' configuration to spec_helper.rb --- spec/spec_helper.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a83b63b..62aa7f6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,24 @@ # frozen_string_literal: true require 'requests_helper' +require 'simplecov' + +SimpleCov.start 'rails' do + add_filter 'app/channels/' + add_filter 'app/jobs/application_job.rb' + add_filter '/app/mailers/' + add_filter '/app/models/application_record.rb' + add_filter '/app/controllers/application_controller.rb' + add_filter '/app/models/concerns/' + add_filter '/app/uploaders/' + add_filter '/bin/' + add_filter '/config/' + add_filter '/db/' + add_filter '/lib/' + add_filter '/spec/' + add_filter '/test/' + add_filter '/vendor/' +end # 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`. @@ -93,5 +111,14 @@ # # test failures related to randomization by passing the same `--seed` value # # as the one that triggered the failure. # Kernel.srand config.seed + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups config.include RequestsHelper, type: :request end From 2b49c17c83bf70c3c10eeb1c6678081e45eb522e Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:15:32 +0000 Subject: [PATCH 13/52] refactor: Update error handling for controllers --- app/controllers/application_controller.rb | 39 ++++++++++++++++------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1e75f48..567861d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,34 +9,49 @@ class ApplicationController < ActionController::API rescue_from NoMethodError, NameError, with: :internal_server_error rescue_from ActionController::RoutingError, with: :invalid_route - def render_error(message: 'An error occurred', status: :bad_request, - details: nil) + def render_error( + error: 'An error occurred', status: :internal_server_error, details: nil + ) numeric_status_code = Rack::Utils.status_code(status) - render json: { message:, status_code: numeric_status_code, - success: false, details: }, - status: numeric_status_code + render json: { + error:, status_code: numeric_status_code, + success: false, details: + }, + status: numeric_status_code end def invalid_route - render_error(message: 'Route not found', - details: { path: request.path, - method: request.method }, status: :not_found) + render_error(error: 'Route not found', + details: { + path: request.path, + method: request.method + }, status: :not_found) end private # Handles exceptions raised when database objects are not found def object_not_found(error) - render_error(details: error.message, status: :not_found) + render_error(error: 'Object not found', details: error.message, + status: :not_found) end def duplicate_object(error) - render_error(details: error.message, status: :conflict) + # Extract the relevant information from the error message + match_data = error.message.match(/Key \((.+)\)=\((.+)\) already exists/) + if match_data + field, value = match_data.captures + details = "#{field.capitalize} '#{value}' already exists." + else + details = 'Duplicate object found.' + end + + render_error(error: 'Duplicate object found', details:, status: :conflict) end def internal_server_error(_error) - render_error(message: 'Internal Server Error', - status: :internal_server_error) + render_error(error: 'Internal Server Error', + status: :internal_server_error) end end From 26bf3fe41ee59d6fbbb3c3b96916da656881f50c Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:19:33 +0000 Subject: [PATCH 14/52] feat: Add serializer for Category model --- app/serializers/category_serializer.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 app/serializers/category_serializer.rb diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb new file mode 100644 index 0000000..b8d51f3 --- /dev/null +++ b/app/serializers/category_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Category Serializer +class CategorySerializer + include JSONAPI::Serializer + + attributes :name, :description, :developer_id, :created_at, :updated_at + + cache_options store: Rails.cache, namespace: 'jsonapi-serializer', + expires_in: 1.hour + + attribute :links do |category| + { self: Rails.application.routes.url_helpers.api_v1_category_url(category) } + end +end From 57bf179ef837f6643de5484e496b6bc23ef776e3 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:20:42 +0000 Subject: [PATCH 15/52] feat: Add JsonResponse and PaginationHelper for API responses --- app/controllers/concerns/json_response.rb | 73 +++++++++++++++++++ app/controllers/concerns/pagination_helper.rb | 30 ++++++++ 2 files changed, 103 insertions(+) create mode 100644 app/controllers/concerns/json_response.rb create mode 100644 app/controllers/concerns/pagination_helper.rb diff --git a/app/controllers/concerns/json_response.rb b/app/controllers/concerns/json_response.rb new file mode 100644 index 0000000..74d4254 --- /dev/null +++ b/app/controllers/concerns/json_response.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# JsonResponse provides a standardized way to render JSON responses in +# the API. It handles both paginated and single resource responses, +# merging common meta information. +module JsonResponse + extend ActiveSupport::Concern + include PaginationHelper + + # Renders the appropriate JSON response based on whether the resource is + # paginated. + # + # @param resource [Object] The resource to render, can be a collection or + # a single object. + # @param message [String] A custom message to include in the response + # metadata (default: 'Request successful'). + # @param extra_meta [Hash] Additional metadata to include in the response + # (default: {}). + def json_response(resource, serializer:, message: 'Request successful', + extra_meta: {}) + if paginated?(resource) + render_paginated(resource, message:, extra_meta:, serializer:) + else + render_single(resource, message:, extra_meta:, serializer:) + end + end + + private + + # Checks if the resource is paginated. + # + # @param resource [Object] The resource to check. + # @return [Boolean] Returns true if the resource is paginated, false + # otherwise. + def paginated?(resource) + resource.respond_to?(:current_page) && resource.respond_to?(:total_pages) + end + + # Renders a paginated JSON response. + # + # @param resource [Object] The paginated resource to render. + # @param message [String] A custom message to include in the response + # metadata. + # @param extra_meta [Hash] Additional metadata to include in the response. + # @return [Hash] The complete JSON response with data, meta, and links. + def render_paginated(resource, message:, extra_meta:, serializer:) + # Use the paginate method from PaginationHelper + pagination_data = paginate(resource, message:) + + # Merge the extra metadata into the pagination meta + meta = pagination_data[:meta].merge(extra_meta) + + # Render the response + serializer.new(resource).serializable_hash.merge( + meta:, links: pagination_data[:links] + ) + end + + # Renders a single JSON response. + # + # @param resource [Object] The single resource to render. + # @param message [String] A custom message to include in the response + # metadata. + # @param extra_meta [Hash] Additional metadata to include in the response. + # @return [Hash] The complete JSON response with data and meta. + def render_single(resource, message:, extra_meta:, serializer:) + meta = { + message: + }.merge(extra_meta) + + serializer.new(resource).serializable_hash.merge(meta:) + end +end diff --git a/app/controllers/concerns/pagination_helper.rb b/app/controllers/concerns/pagination_helper.rb new file mode 100644 index 0000000..d4296d3 --- /dev/null +++ b/app/controllers/concerns/pagination_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Helper for API response pagination +module PaginationHelper + # rubocop:disable Metrics/MethodLength + + def paginate(resource, message: 'Records retrieved successfully') + page = resource.current_page + total_pages = resource.total_pages + page_size = resource.limit_value + + links = { + first: url_for(page: 1, page_size:), + last: url_for(page: total_pages, page_size:), + prev: (url_for(page: page - 1, page_size:) if page > 1), + next: (url_for(page: page + 1, page_size:) if page < total_pages) + } + + { + meta: { + total_count: resource.total_count, + current_count: resource.count, + message: + }, + links: + } + end +end + +# rubocop:enable Metrics/MethodLength From 2bf07bcca8c87bdcf8115d29ef5d5524c8118723 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:26:20 +0000 Subject: [PATCH 16/52] test: Add unit tests for CategoriesController to validate all actions --- spec/requests/api/v1/categories_spec.rb | 514 ++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 spec/requests/api/v1/categories_spec.rb diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb new file mode 100644 index 0000000..2c4b725 --- /dev/null +++ b/spec/requests/api/v1/categories_spec.rb @@ -0,0 +1,514 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "/api/v1/categories", type: :request do + let(:valid_attributes) do + { name: 'Home Appliance', + description: 'Everything home appliance goes here' } + end + + let(:first_developer_token) { UUID7.generate } + let(:second_developer_token) { UUID7.generate } + + let(:invalid_attributes) do + { name: '', description: '' } # no category name and description + end + + # initial validation validation is done for the first developer token + let!(:first_dev_headers) do + { + 'Content-Type' => 'application/json', + 'X-Developer-Token' => first_developer_token + } + end + + let!(:second_dev_headers) do + { + 'Content-Type' => 'application/json', + 'X-Developer-Token' => second_developer_token + } + end + + context 'with valid developer token' do + let!(:first_dev_category) do + Category.create! valid_attributes.merge( + developer_id: first_developer_token + ) + end + + let!(:second_dev_category) do + Category.create!( + name: 'Computer Accessories', + description: 'Contains products such as keyboards mice, etc.', + developer_id: second_developer_token + ) + end + + # ensure that there are two categories at the start of the test + it 'starts with 2 categories in the database' do + expect(Category.count).to eq(2) + end + + describe "GET /index" do + it "renders a successful response" do + get api_v1_categories_url, headers: first_dev_headers + expect(response).to be_successful + end + + it "contains data object for actual response data" do + get api_v1_categories_url, headers: first_dev_headers + expect(response_body).to include(:data) + end + + it 'contains RESTful meta information' do + get api_v1_categories_url, headers: first_dev_headers + expect(response).to have_http_status(:ok) + + expect(response_body).to have_key(:meta) + meta = response_body[:meta] + + expect(meta).to include(:message, :total_count, :current_count) + end + + describe 'Authorization' do + it 'rejects requests without a developer token' do + get api_v1_categories_url + expect(response).to have_http_status(:unauthorized) + end + + it 'rejects requests with an invalid developer token' do + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:valid_developer_token?).and_return(false) + + get api_v1_categories_url, + headers: { 'X-Developer-Token' => UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + end + + it 'permits developers with valid tokens in the headers' do + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:valid_developer_token?).and_return(true) + + get api_v1_categories_url, + headers: first_dev_headers + expect(response).to have_http_status(:ok) + end + end + + describe 'Pagination' do + it 'contains pagination links' do + get api_v1_categories_url, headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body).to have_key(:links) + + pagination_links = response_body[:links] + + expect(pagination_links).to include(:first, :last, :prev, :next) + + expect(pagination_links[:prev]).to eq(nil) + expect(pagination_links[:next]).to eq(nil) + + expect(pagination_links[:first]).to eq( + "#{request.url}?page=1&page_size=100" + ) + expect(pagination_links[:last]).to eq( + "#{request.url}?page=1&page_size=100" + ) + end + + it 'responds to page_size query parameter' do + get api_v1_categories_url, params: { page_size: 1 }, + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + + # the first developer has only one category created so far + expect(response_body.dig(:meta, :current_count)).to eq(1) + expect(response_body.dig(:meta, :total_count)).to eq(1) + expect(response_body[:data].size).to eq(1) + end + + it 'limits the page size to 100' do + get api_v1_categories_url, params: { page_size: 101 }, + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:meta][:current_count]).to be <= 100 + expect(response_body.dig(:links, :first)).to eq( + "#{request.base_url}/api/v1/categories?page=1&page_size=100" + ) + end + end + + describe 'Response data body' do + let!(:categories) { [Category.first, Category.last] } + + it 'has all the required and follows the JSON:API standard' do + get api_v1_categories_url, headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data]).to be_an(Array) + + response_data = response_body[:data] + + response_data.each_with_index do |data, index| + expect(data).to include(:id, :type, :attributes) + expect(data[:attributes]).to be_a(Hash) + expect(data[:attributes]).to include( + :name, :description, + :developer_id, :created_at, :updated_at, :links + ) + expect(data.dig(:attributes, :links)).to be_a(Hash) + + expect(data.dig(:attributes, :links)).to have_key(:self) + expect( + data.dig(:attributes, :links, :self) + ).to eq(api_v1_category_url(categories[index])) + end + end + + it 'has the same data as the ones stored in the database' do + get api_v1_categories_url, headers: first_dev_headers + + expect(response).to have_http_status(:ok) + response_data = response_body[:data] + + response_data.each_with_index do |data, index| + category = categories[index] + + expect(data[:id]).to eq(category.id) + expect(data.dig(:attributes, :name)).to eq(category.name) + expect(data.dig(:attributes, + :developer_id)).to eq(category.developer_id) + expect(data.dig(:attributes, + :description)).to eq(category.description) + expect(data.dig(:attributes, + :created_at)).to eq(category.created_at.iso8601(3)) + expect(data.dig(:attributes, + :updated_at)).to eq(category.updated_at.iso8601(3)) + end + end + + it 'contains only the resources owned by the authenticated developer' do + get api_v1_categories_url, headers: first_dev_headers + + expect(response).to have_http_status(:ok) + response_data = response_body[:data] + + response_data.each do |data| + expect(data.dig(:attributes, :developer_id)).to eq( + first_dev_headers['X-Developer-Token'] + ) + end + end + end + end + + describe "GET /show" do + context "with the first developer's token" do + it "renders a successful response" do + get api_v1_category_url(first_dev_category), + headers: first_dev_headers + expect(response).to be_successful + end + + it "contains data object for actual response data" do + get api_v1_category_url(first_dev_category), + headers: first_dev_headers + expect(response_body).to include(:data) + end + + it "contains the exact data for the category requested" do + get api_v1_category_url(first_dev_category), + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data]).to include(:id, :type, :attributes) + + response_data = response_body[:data] + + expect(response_data.dig(:attributes, :name)).to eq( + first_dev_category.name + ) + expect(response_data.dig(:attributes, :description)).to eq( + first_dev_category.description + ) + expect(response_data.dig(:attributes, :developer_id)).to eq( + first_dev_category.developer_id + ) + end + + it "gets a 404 when the category is not found" do + get api_v1_category_url(UUID7.generate), headers: first_dev_headers + expect(response).to have_http_status(:not_found) + end + + it "rejects requests from developers who do not own the category" do + get api_v1_category_url(first_dev_category), + headers: second_dev_headers + + expect(response).to have_http_status(:not_found) + end + end + + context "with the second developer's token" do + it "renders a successful response" do + get api_v1_category_url(second_dev_category), + headers: second_dev_headers + expect(response).to be_successful + end + + it "contains data object for actual response data" do + get api_v1_category_url(second_dev_category), + headers: second_dev_headers + expect(response_body).to include(:data) + end + + it "contains the exact data for the category requested" do + get api_v1_category_url(second_dev_category), + headers: second_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data]).to include(:id, :type, :attributes) + + response_data = response_body[:data] + + expect(response_data.dig(:attributes, :name)).to eq( + second_dev_category.name + ) + expect(response_data.dig(:attributes, :description)).to eq( + second_dev_category.description + ) + expect(response_data.dig(:attributes, :developer_id)).to eq( + second_dev_category.developer_id + ) + end + + it "gets a 404 when the category is not found" do + get api_v1_category_url(UUID7.generate), headers: second_dev_headers + expect(response).to have_http_status(:not_found) + end + + it "rejects requests from developers who do not own the category" do + get api_v1_category_url(second_dev_category), + headers: first_dev_headers + + expect(response).to have_http_status(:not_found) + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new Category" do + expect do + post api_v1_categories_url, + params: { + category: { + name: 'Body Lotions', + description: 'This category is for skin care products' + } + }, + headers: first_dev_headers, as: :json + end.to change(Category, :count).by(1) + + expect(response).to have_http_status(:created) + expect(response.content_type).to include('application/json') + + expect(response_body[:data]).to include(:id, :type, :attributes) + expect(response_body[:data][:attributes]).to include( + :name, :description, :developer_id, :created_at, + :updated_at, :links + ) + + # now let's validate the response data + response_data = response_body[:data] + + expect(response_data.dig(:attributes, + :name)).to eq('Body Lotions') + end + + it 'sends a 409 error when duplicates are attempted' do + expect do + post api_v1_categories_url, + params: { + category: { + name: first_dev_category.name, + description: first_dev_category.description + } + }, + headers: first_dev_headers, as: :json + end.not_to change(Category, :count) + + expect(response).to have_http_status(:conflict) + + expect(response_body[:error]).to eq("Duplicate object found") + end + + it "renders a JSON response with the newly created category" do + post api_v1_categories_url, + params: { + category: { + name: 'Body Lotions', + description: 'This category is for skin care products' + } + }, + headers: first_dev_headers, as: :json + + expect(response).to have_http_status(:created) + expect(response.content_type).to include("application/json") + end + end + + context "with invalid parameters" do + it "does not create a new Category" do + expect do + post api_v1_categories_url, + headers: first_dev_headers, + params: { api_v1_category: invalid_attributes }, as: :json + end.to change(Category, :count).by(0) + end + + it "renders a JSON response with errors for the new category" do + post api_v1_categories_url, + params: { category: invalid_attributes }, + headers: first_dev_headers, as: :json + expect(response).to have_http_status(:unprocessable_content) + expect(response.content_type).to include("application/json") + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) do + { + description: 'This category holds all home appliances products', + name: 'UK Home-used Appliances' + } + end + + it "updates the requested category" do + patch api_v1_category_url(first_dev_category), + params: { category: new_attributes }, + headers: first_dev_headers, as: :json + + first_dev_category.reload + expect(response).to have_http_status(:ok) + + expect(first_dev_category.description).to eq( + new_attributes[:description] + ) + expect(first_dev_category.name).to eq(new_attributes[:name]) + end + + it "renders a JSON response with the category" do + patch api_v1_category_url(first_dev_category), + params: { category: new_attributes }, + headers: first_dev_headers, as: :json + + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + end + + it "rejects updates from developers who do not own the category" do + patch api_v1_category_url(first_dev_category), + params: { category: new_attributes }, + headers: second_dev_headers, as: :json + + # for the sake of security, we will just tell them the item wasn't + # found because they don't own it. + expect(response).to have_http_status(:not_found) + end + end + + context "with invalid parameters" do + it "renders a JSON response with errors for the category" do + patch api_v1_category_url(first_dev_category), + params: { category: invalid_attributes }, + headers: first_dev_headers, as: :json + expect(response).to have_http_status(:unprocessable_content) + + expect(response.content_type).to include("application/json") + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested category if it exists" do + expect do + delete api_v1_category_url(first_dev_category), + headers: first_dev_headers, as: :json + end.to change(Category, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'returns 404 when the requested category is not found' do + expect do + delete api_v1_category_url(UUID7.generate), + headers: first_dev_headers, as: :json + end.not_to change(Category, :count) + + expect(response).to have_http_status(:not_found) + end + + it 'returns an empty response body on success' do + expect do + delete api_v1_category_url(first_dev_category), + headers: first_dev_headers, + as: :json + end.to change(Category, :count).by(-1) + + expect(response).to have_http_status(:no_content) + expect(response.body.empty?).to be(true) + end + + it "rejects deletions from developers who do not own the category" do + delete api_v1_category_url(first_dev_category), + headers: second_dev_headers, as: :json + + # for the sake of security, we will just tell them the item wasn't + # found because they don't own it. + expect(response).to have_http_status(:not_found) + end + end + end + + context 'with an invalid or missing developer token' do + it 'returns a 401 on the /index route' do + get api_v1_categories_url + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns a 401 on the /create route' do + post api_v1_categories_url + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns a 401 on the /show route' do + get api_v1_category_url(UUID7.generate) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns a 401 on the /update (PUT | PATCH) route' do + put api_v1_category_url(UUID7.generate) + expect(response).to have_http_status(:unauthorized) + + patch api_v1_category_url(UUID7.generate) + expect(response).to have_http_status(:unauthorized) + end + + it 'returns a 401 on the /destroy route' do + delete api_v1_category_url(UUID7.generate) + + expect(response).to have_http_status(:unauthorized) + end + end + end +end From 9cf3a9c6d97b03638d240e52de73d270375ec3a6 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:27:14 +0000 Subject: [PATCH 17/52] feat: Add controller for category API operations --- .../api/v1/categories_controller.rb | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/controllers/api/v1/categories_controller.rb diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb new file mode 100644 index 0000000..4b339da --- /dev/null +++ b/app/controllers/api/v1/categories_controller.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Api + module V1 + # Controller for version of Categories side of Product Management Service + # This controller handles CRUD operations for categories. + class CategoriesController < ApplicationController + # Maximum number of items to be returned in a single page + MAX_PAGINATION_SIZE = 100 + + # Before actions to set up authorization and category object + before_action :authorization_credentials + before_action :set_api_v1_category, only: %i[show update destroy] + + # GET /api/v1/categories + # GET /api/v1/categories.json + # Retrieves a paginated list of categories filtered by developer token + def index + # Fetch categories with pagination + categories = Category.page(params.fetch(:page, 1)).per(page_size) + + # Filter categories by developer token + categories = categories.where(developer_id: developer_token) + + # Render the JSON response with the categories + render json: json_response( + categories, serializer:, + message: 'Categories retrieved successfully' + ) + end + + # GET /api/v1/categories/1 + # GET /api/v1/categories/1.json + # Retrieves a specific category by ID + def show + # Render the JSON response with the category + render json: json_response( + category, serializer:, + message: 'Category retrieved successfully' + ) + end + + # POST /api/v1/categories + # POST /api/v1/categories.json + # Creates a new category + def create + @category = Category.new(api_v1_category_params) + + if @category.save + # Render the JSON response with the created category + render json: json_response(@category, + message: 'Category created successfully', + serializer:), status: :created + else + # Render an error response if the category could not be created + render_error(error: category.errors, + status: :unprocessable_content) + end + end + + # PATCH/PUT /api/v1/categories/1 + # PATCH/PUT /api/v1/categories/1.json + # Updates an existing category + def update + if @category.update(api_v1_category_params) + # Render the JSON response with the updated category + render json: json_response(@category, + serializer:, + message: 'Category updated successfully') + else + # Render an error response if the category could not be updated + render json: category.errors, status: :unprocessable_content + end + end + + # DELETE /api/v1/categories/1 + # DELETE /api/v1/categories/1.json + # Deletes a specific category by ID + def destroy + @category.destroy! + # Respond with no content status + head :no_content + end + + private + + # Reader method for the category instance variable + attr_reader :category + + # Determines the page size for pagination, ensuring it does not exceed the maximum limit + def page_size + [ + params.fetch(:page_size, MAX_PAGINATION_SIZE).to_i, + MAX_PAGINATION_SIZE + ].min + end + + # Use callbacks to share common setup or constraints between actions. + # Sets the category instance variable based on the ID and developer token + def set_api_v1_category + @category = Category.find_by!( + id: params[:id], + developer_id: developer_token + ) + end + + # Only allow a list of trusted parameters through. + # Permits the name and description parameters and merges the developer token + def api_v1_category_params + params.require(:category) + .permit(:name, :description) + .merge(developer_id: developer_token) + end + + # Returns the serializer class for the category + def serializer + CategorySerializer + end + + # Returns the developer token from the request headers + def developer_token + request.headers.fetch('X-Developer-Token', nil) + end + + # Validates the developer token before processing the request + def authorization_credentials + return if valid_developer_token? + + # Render an error response if the developer token is invalid + render_error( + error: 'Authorization failed', + details: { + error: 'Invalid developer token', + message: 'Please provide a valid developer token' + }, + status: :unauthorized + ) + end + + # This method calls the user service API to validate the developer token + # + # TODO: Implement the actual API call to the user service + def valid_developer_token? + # Placeholder for actual API call + # response = UserApiService.validate_token(developer_token) + # + # return true if response.code == 200 + # + # false + + # Temporary validation logic + if developer_token.nil? + false + else + true + end + end + end + end +end From e1c6de593038267bdb87575d339bc0afaa0a9a6d Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:30:01 +0000 Subject: [PATCH 18/52] style: Update code style --- .../api/v1/categories_controller.rb | 18 +-- app/controllers/concerns/json_response.rb | 6 +- app/serializers/category_serializer.rb | 2 +- spec/requests/api/v1/categories_spec.rb | 110 +++++++++--------- 4 files changed, 68 insertions(+), 68 deletions(-) diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 4b339da..8334c8f 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -25,7 +25,7 @@ def index # Render the JSON response with the categories render json: json_response( categories, serializer:, - message: 'Categories retrieved successfully' + message: 'Categories retrieved successfully' ) end @@ -36,7 +36,7 @@ def show # Render the JSON response with the category render json: json_response( category, serializer:, - message: 'Category retrieved successfully' + message: 'Category retrieved successfully' ) end @@ -49,12 +49,12 @@ def create if @category.save # Render the JSON response with the created category render json: json_response(@category, - message: 'Category created successfully', - serializer:), status: :created + message: 'Category created successfully', + serializer:), status: :created else # Render an error response if the category could not be created render_error(error: category.errors, - status: :unprocessable_content) + status: :unprocessable_content) end end @@ -65,8 +65,8 @@ def update if @category.update(api_v1_category_params) # Render the JSON response with the updated category render json: json_response(@category, - serializer:, - message: 'Category updated successfully') + serializer:, + message: 'Category updated successfully') else # Render an error response if the category could not be updated render json: category.errors, status: :unprocessable_content @@ -108,8 +108,8 @@ def set_api_v1_category # Permits the name and description parameters and merges the developer token def api_v1_category_params params.require(:category) - .permit(:name, :description) - .merge(developer_id: developer_token) + .permit(:name, :description) + .merge(developer_id: developer_token) end # Returns the serializer class for the category diff --git a/app/controllers/concerns/json_response.rb b/app/controllers/concerns/json_response.rb index 74d4254..fb7623f 100644 --- a/app/controllers/concerns/json_response.rb +++ b/app/controllers/concerns/json_response.rb @@ -17,7 +17,7 @@ module JsonResponse # @param extra_meta [Hash] Additional metadata to include in the response # (default: {}). def json_response(resource, serializer:, message: 'Request successful', - extra_meta: {}) + extra_meta: {}) if paginated?(resource) render_paginated(resource, message:, extra_meta:, serializer:) else @@ -65,8 +65,8 @@ def render_paginated(resource, message:, extra_meta:, serializer:) # @return [Hash] The complete JSON response with data and meta. def render_single(resource, message:, extra_meta:, serializer:) meta = { - message: - }.merge(extra_meta) + message: + }.merge(extra_meta) serializer.new(resource).serializable_hash.merge(meta:) end diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index b8d51f3..3b4b5e8 100644 --- a/app/serializers/category_serializer.rb +++ b/app/serializers/category_serializer.rb @@ -7,7 +7,7 @@ class CategorySerializer attributes :name, :description, :developer_id, :created_at, :updated_at cache_options store: Rails.cache, namespace: 'jsonapi-serializer', - expires_in: 1.hour + expires_in: 1.hour attribute :links do |category| { self: Rails.application.routes.url_helpers.api_v1_category_url(category) } diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 2c4b725..b701010 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -82,7 +82,7 @@ receive(:valid_developer_token?).and_return(false) get api_v1_categories_url, - headers: { 'X-Developer-Token' => UUID7.generate } + headers: { 'X-Developer-Token' => UUID7.generate } expect(response).to have_http_status(:unauthorized) end @@ -92,7 +92,7 @@ receive(:valid_developer_token?).and_return(true) get api_v1_categories_url, - headers: first_dev_headers + headers: first_dev_headers expect(response).to have_http_status(:ok) end end @@ -121,7 +121,7 @@ it 'responds to page_size query parameter' do get api_v1_categories_url, params: { page_size: 1 }, - headers: first_dev_headers + headers: first_dev_headers expect(response).to have_http_status(:ok) @@ -133,7 +133,7 @@ it 'limits the page size to 100' do get api_v1_categories_url, params: { page_size: 101 }, - headers: first_dev_headers + headers: first_dev_headers expect(response).to have_http_status(:ok) expect(response_body[:meta][:current_count]).to be <= 100 @@ -182,13 +182,13 @@ expect(data[:id]).to eq(category.id) expect(data.dig(:attributes, :name)).to eq(category.name) expect(data.dig(:attributes, - :developer_id)).to eq(category.developer_id) + :developer_id)).to eq(category.developer_id) expect(data.dig(:attributes, - :description)).to eq(category.description) + :description)).to eq(category.description) expect(data.dig(:attributes, - :created_at)).to eq(category.created_at.iso8601(3)) + :created_at)).to eq(category.created_at.iso8601(3)) expect(data.dig(:attributes, - :updated_at)).to eq(category.updated_at.iso8601(3)) + :updated_at)).to eq(category.updated_at.iso8601(3)) end end @@ -211,19 +211,19 @@ context "with the first developer's token" do it "renders a successful response" do get api_v1_category_url(first_dev_category), - headers: first_dev_headers + headers: first_dev_headers expect(response).to be_successful end it "contains data object for actual response data" do get api_v1_category_url(first_dev_category), - headers: first_dev_headers + headers: first_dev_headers expect(response_body).to include(:data) end it "contains the exact data for the category requested" do get api_v1_category_url(first_dev_category), - headers: first_dev_headers + headers: first_dev_headers expect(response).to have_http_status(:ok) expect(response_body[:data]).to include(:id, :type, :attributes) @@ -248,7 +248,7 @@ it "rejects requests from developers who do not own the category" do get api_v1_category_url(first_dev_category), - headers: second_dev_headers + headers: second_dev_headers expect(response).to have_http_status(:not_found) end @@ -257,19 +257,19 @@ context "with the second developer's token" do it "renders a successful response" do get api_v1_category_url(second_dev_category), - headers: second_dev_headers + headers: second_dev_headers expect(response).to be_successful end it "contains data object for actual response data" do get api_v1_category_url(second_dev_category), - headers: second_dev_headers + headers: second_dev_headers expect(response_body).to include(:data) end it "contains the exact data for the category requested" do get api_v1_category_url(second_dev_category), - headers: second_dev_headers + headers: second_dev_headers expect(response).to have_http_status(:ok) expect(response_body[:data]).to include(:id, :type, :attributes) @@ -294,7 +294,7 @@ it "rejects requests from developers who do not own the category" do get api_v1_category_url(second_dev_category), - headers: first_dev_headers + headers: first_dev_headers expect(response).to have_http_status(:not_found) end @@ -305,13 +305,13 @@ it "creates a new Category" do expect do post api_v1_categories_url, - params: { - category: { - name: 'Body Lotions', - description: 'This category is for skin care products' - } - }, - headers: first_dev_headers, as: :json + params: { + category: { + name: 'Body Lotions', + description: 'This category is for skin care products' + } + }, + headers: first_dev_headers, as: :json end.to change(Category, :count).by(1) expect(response).to have_http_status(:created) @@ -327,19 +327,19 @@ response_data = response_body[:data] expect(response_data.dig(:attributes, - :name)).to eq('Body Lotions') + :name)).to eq('Body Lotions') end it 'sends a 409 error when duplicates are attempted' do expect do post api_v1_categories_url, - params: { - category: { - name: first_dev_category.name, - description: first_dev_category.description - } - }, - headers: first_dev_headers, as: :json + params: { + category: { + name: first_dev_category.name, + description: first_dev_category.description + } + }, + headers: first_dev_headers, as: :json end.not_to change(Category, :count) expect(response).to have_http_status(:conflict) @@ -349,13 +349,13 @@ it "renders a JSON response with the newly created category" do post api_v1_categories_url, - params: { - category: { - name: 'Body Lotions', - description: 'This category is for skin care products' - } - }, - headers: first_dev_headers, as: :json + params: { + category: { + name: 'Body Lotions', + description: 'This category is for skin care products' + } + }, + headers: first_dev_headers, as: :json expect(response).to have_http_status(:created) expect(response.content_type).to include("application/json") @@ -366,15 +366,15 @@ it "does not create a new Category" do expect do post api_v1_categories_url, - headers: first_dev_headers, - params: { api_v1_category: invalid_attributes }, as: :json + headers: first_dev_headers, + params: { api_v1_category: invalid_attributes }, as: :json end.to change(Category, :count).by(0) end it "renders a JSON response with errors for the new category" do post api_v1_categories_url, - params: { category: invalid_attributes }, - headers: first_dev_headers, as: :json + params: { category: invalid_attributes }, + headers: first_dev_headers, as: :json expect(response).to have_http_status(:unprocessable_content) expect(response.content_type).to include("application/json") end @@ -392,8 +392,8 @@ it "updates the requested category" do patch api_v1_category_url(first_dev_category), - params: { category: new_attributes }, - headers: first_dev_headers, as: :json + params: { category: new_attributes }, + headers: first_dev_headers, as: :json first_dev_category.reload expect(response).to have_http_status(:ok) @@ -406,8 +406,8 @@ it "renders a JSON response with the category" do patch api_v1_category_url(first_dev_category), - params: { category: new_attributes }, - headers: first_dev_headers, as: :json + params: { category: new_attributes }, + headers: first_dev_headers, as: :json expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") @@ -415,8 +415,8 @@ it "rejects updates from developers who do not own the category" do patch api_v1_category_url(first_dev_category), - params: { category: new_attributes }, - headers: second_dev_headers, as: :json + params: { category: new_attributes }, + headers: second_dev_headers, as: :json # for the sake of security, we will just tell them the item wasn't # found because they don't own it. @@ -427,8 +427,8 @@ context "with invalid parameters" do it "renders a JSON response with errors for the category" do patch api_v1_category_url(first_dev_category), - params: { category: invalid_attributes }, - headers: first_dev_headers, as: :json + params: { category: invalid_attributes }, + headers: first_dev_headers, as: :json expect(response).to have_http_status(:unprocessable_content) expect(response.content_type).to include("application/json") @@ -440,7 +440,7 @@ it "destroys the requested category if it exists" do expect do delete api_v1_category_url(first_dev_category), - headers: first_dev_headers, as: :json + headers: first_dev_headers, as: :json end.to change(Category, :count).by(-1) expect(response).to have_http_status(:no_content) @@ -449,7 +449,7 @@ it 'returns 404 when the requested category is not found' do expect do delete api_v1_category_url(UUID7.generate), - headers: first_dev_headers, as: :json + headers: first_dev_headers, as: :json end.not_to change(Category, :count) expect(response).to have_http_status(:not_found) @@ -458,8 +458,8 @@ it 'returns an empty response body on success' do expect do delete api_v1_category_url(first_dev_category), - headers: first_dev_headers, - as: :json + headers: first_dev_headers, + as: :json end.to change(Category, :count).by(-1) expect(response).to have_http_status(:no_content) @@ -468,7 +468,7 @@ it "rejects deletions from developers who do not own the category" do delete api_v1_category_url(first_dev_category), - headers: second_dev_headers, as: :json + headers: second_dev_headers, as: :json # for the sake of security, we will just tell them the item wasn't # found because they don't own it. From ed3498dba737bbf5bac4d36cf6c7e34167b80c3d Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:21:45 +0000 Subject: [PATCH 19/52] test: Add unit tests for validating filtering by name and search keywords --- spec/requests/api/v1/categories_spec.rb | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index b701010..b340add 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -205,6 +205,65 @@ end end end + + context 'with query parameters in the request path' do + before do + Category.create!( + name: 'Body lotions', description: 'Skin care products', + developer_id: first_developer_token + ) + end + + context 'with filters based on the name' do + it 'filters the categories based on the name' do + get api_v1_categories_url, + params: { name: 'Body lotions' }, + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data].size).to eq(1) + + response_data = response_body[:data] + + expect(response_data.first.dig(:attributes, :name)).to \ + eq('Body lotions') + end + + it 'returns an empty array when no match is found' do + get api_v1_categories_url, + params: { name: 'Body lotions' }, + headers: second_dev_headers # second dev does not have this one + + expect(response).to have_http_status(:ok) + expect(response_body[:data].size).to eq(0) + end + + it 'returns all categories when the filter is not found' do + get api_v1_categories_url, + params: { unknown: 'Body lotions' }, + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data].size).to eq(2) + end + end + + context "with filters based on the 'search' keyword" do + it 'returns all categories with the searched keyword' do + get api_v1_categories_url, + params: { search: 'home' }, + headers: first_dev_headers + + expect(response).to have_http_status(:ok) + expect(response_body[:data].size).to eq(1) + + response_data = response_body[:data] + + expect(response_data.first.dig(:attributes, :name)).to \ + eq('Home Appliance') + end + end + end end describe "GET /show" do From b1d81cbc53e27d328b6a3e414c2403a786a84f64 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:26:24 +0000 Subject: [PATCH 20/52] feat: Implement filtering based on name and search keywords --- .../api/v1/categories_controller.rb | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 8334c8f..e8f2f0b 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -19,8 +19,7 @@ def index # Fetch categories with pagination categories = Category.page(params.fetch(:page, 1)).per(page_size) - # Filter categories by developer token - categories = categories.where(developer_id: developer_token) + categories = perform_filtering(categories) # Render the JSON response with the categories render json: json_response( @@ -87,7 +86,8 @@ def destroy # Reader method for the category instance variable attr_reader :category - # Determines the page size for pagination, ensuring it does not exceed the maximum limit + # Determines the page size for pagination, ensuring it does not exceed + # the maximum limit def page_size [ params.fetch(:page_size, MAX_PAGINATION_SIZE).to_i, @@ -95,8 +95,8 @@ def page_size ].min end - # Use callbacks to share common setup or constraints between actions. - # Sets the category instance variable based on the ID and developer token + # Sets the category instance variable based on the ID and developer + # token def set_api_v1_category @category = Category.find_by!( id: params[:id], @@ -105,7 +105,8 @@ def set_api_v1_category end # Only allow a list of trusted parameters through. - # Permits the name and description parameters and merges the developer token + # Permits the name and description parameters and merges the developer + # token def api_v1_category_params params.require(:category) .permit(:name, :description) @@ -155,6 +156,25 @@ def valid_developer_token? true end end + + def perform_filtering(categories) + # Filter categories by developer token + categories = categories.where(developer_id: developer_token) + + # Filter categories by name + if params[:name].present? + categories = categories.where('name ILIKE ?', "%#{params[:name]}%") + end + + # filter categories by the search term, so any category that + # has the search term in its name or description will be returned + return categories if params[:search].blank? + + categories.where( + 'name ILIKE :search OR description ILIKE :search', + search: "%#{params[:search]}%" + ) + end end end end From a62c7ebdf592db43118e10b1841e2e92c0e61b97 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:42:02 +0000 Subject: [PATCH 21/52] refactor: Move authentication validation to application controller --- .../api/v1/categories_controller.rb | 56 ++----------------- 1 file changed, 6 insertions(+), 50 deletions(-) diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index e8f2f0b..7cf6fbc 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -9,7 +9,6 @@ class CategoriesController < ApplicationController MAX_PAGINATION_SIZE = 100 # Before actions to set up authorization and category object - before_action :authorization_credentials before_action :set_api_v1_category, only: %i[show update destroy] # GET /api/v1/categories @@ -45,16 +44,12 @@ def show def create @category = Category.new(api_v1_category_params) - if @category.save - # Render the JSON response with the created category - render json: json_response(@category, - message: 'Category created successfully', - serializer:), status: :created - else - # Render an error response if the category could not be created - render_error(error: category.errors, - status: :unprocessable_content) - end + @category.save! # Raise an error when the category could not be saved + + # Render the JSON response with the created category + render json: json_response(@category, + message: 'Category created successfully', + serializer:), status: :created end # PATCH/PUT /api/v1/categories/1 @@ -118,45 +113,6 @@ def serializer CategorySerializer end - # Returns the developer token from the request headers - def developer_token - request.headers.fetch('X-Developer-Token', nil) - end - - # Validates the developer token before processing the request - def authorization_credentials - return if valid_developer_token? - - # Render an error response if the developer token is invalid - render_error( - error: 'Authorization failed', - details: { - error: 'Invalid developer token', - message: 'Please provide a valid developer token' - }, - status: :unauthorized - ) - end - - # This method calls the user service API to validate the developer token - # - # TODO: Implement the actual API call to the user service - def valid_developer_token? - # Placeholder for actual API call - # response = UserApiService.validate_token(developer_token) - # - # return true if response.code == 200 - # - # false - - # Temporary validation logic - if developer_token.nil? - false - else - true - end - end - def perform_filtering(categories) # Filter categories by developer token categories = categories.where(developer_id: developer_token) From 550f5c8b2c4870acb009cdd98697a787d7d00086 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:44:16 +0000 Subject: [PATCH 22/52] refactor: Update error handling responses and include Authentication mixin --- app/controllers/application_controller.rb | 89 ++++++++++++++++------- 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 567861d..0be417d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,56 +2,93 @@ # Base controller for API controllers class ApplicationController < ActionController::API + include Authentication include JsonResponse rescue_from ActiveRecord::RecordNotFound, with: :object_not_found rescue_from ActiveRecord::RecordNotUnique, with: :duplicate_object + rescue_from ActiveRecord::RecordInvalid, with: :validation_error rescue_from NoMethodError, NameError, with: :internal_server_error rescue_from ActionController::RoutingError, with: :invalid_route - def render_error( - error: 'An error occurred', status: :internal_server_error, details: nil - ) + # rubocop:disable Metrics/MethodLength + + # Renders a JSON error response + def render_error(error:, status:, details: nil, meta: {}) numeric_status_code = Rack::Utils.status_code(status) + success = false + + # Default meta information + default_meta = { + request_path: request.url, + request_method: request.method, + status_code: numeric_status_code, + success: + } + + # Merge default meta with custom meta + final_meta = default_meta.merge(meta) render json: { - error:, status_code: numeric_status_code, - success: false, details: - }, - status: numeric_status_code + error:, + meta: final_meta, + details: + }, status: numeric_status_code end + # rubocop:enable Metrics/MethodLength + def invalid_route - render_error(error: 'Route not found', - details: { - path: request.path, - method: request.method - }, status: :not_found) + render_error( + error: 'Route not found', + details: { message: "Invalid route: #{request.path}" }, + status: :not_found + ) end private # Handles exceptions raised when database objects are not found def object_not_found(error) - render_error(error: 'Object not found', details: error.message, - status: :not_found) + render_error( + error: "#{error.model} not found", + details: { + message: "Couldn't find #{error.model} with id #{params[:id]}" + }, + status: :not_found + ) end + # Handles unique constraint violation errors def duplicate_object(error) - # Extract the relevant information from the error message match_data = error.message.match(/Key \((.+)\)=\((.+)\) already exists/) - if match_data - field, value = match_data.captures - details = "#{field.capitalize} '#{value}' already exists." - else - details = 'Duplicate object found.' - end - - render_error(error: 'Duplicate object found', details:, status: :conflict) + details = if match_data + field, value = match_data.captures + "A record with #{field} '#{value}' already exists." + else + 'A record with that name already exists.' + end + + render_error(error: 'Duplicate object found', + details:, status: :conflict) + end + + # Handles generic server errors like NoMethodError or NameError + def internal_server_error(error) + logger.error "#{error.class.name}: #{error.message}" + render_error( + error: 'Internal Server Error', + # details: { exception: error.class.name, message: error.message }, + status: :internal_server_error + ) end - def internal_server_error(_error) - render_error(error: 'Internal Server Error', - status: :internal_server_error) + # Handles validation errors + def validation_error(error) + render_error( + error: 'Validation Failed', + details: error.record.errors.full_messages.to_sentence, + status: :unprocessable_content + ) end end From a707993f381d28ff23404d8286f0271c5025e5a8 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:45:18 +0000 Subject: [PATCH 23/52] feat: Add an authentication module to validate dev tokens and user IDs --- app/controllers/concerns/authentication.rb | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 app/controllers/concerns/authentication.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..c9a639a --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Authentication mixin +module Authentication + extend ActiveSupport::Concern + + included do + before_action :verify_authentication_credentials! + end + + # Retrieves the developer's token from the headers + def developer_token + request.headers.fetch('X-Developer-Token', nil) + end + + private + + # Validates both developer token and user ID + def verify_authentication_credentials! + verify_developer_token! + + return if skip_user_id_verification? + + begin + verify_user_id! + rescue StandardError + nil + end + end + + # Checks if the request is in CategoriesController + def skip_user_id_verification? + is_a?(Api::V1::CategoriesController) + end + + # Verifies the developer token + def verify_developer_token! + return if valid_developer_token? + + render_error( + error: 'Authorization failed', + details: { + error: 'Invalid developer token', + message: 'Please provide a valid developer token' + }, + status: :unauthorized + ) + end + + # Verifies the user ID + def verify_user_id! + user_id = request.headers.fetch('X-User-ID', nil) + return if valid_user_id?(user_id) + + render_error( + error: 'Authorization failed', + details: { + error: 'Invalid user ID', + message: 'Please provide a valid user ID' + }, + status: :unauthorized + ) + end + + # Placeholder for actual user ID validation logic + def valid_user_id?(user_id) + user_id.present? # Temporary logic + end + + # Placeholder for actual developer token validation logic + def valid_developer_token? + developer_token.present? + end +end From 28f3d5e79d974a47c6229f80af1bad4e2df37818 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:46:11 +0000 Subject: [PATCH 24/52] feat: Add endpoints for products API operations --- config/routes.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/routes.rb b/config/routes.rb index 4be9428..77079e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ Rails.application.routes.draw do namespace :api do namespace :v1 do + resources :products, only: %i[index show create update destroy] resources :categories, only: %i[index show create update destroy] end end From 9e94fe8d24c9e486faf0e1ca550d613bf86f7a06 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:08:31 +0000 Subject: [PATCH 25/52] chore: Add database cleaner for active record --- Gemfile | 4 ++++ Gemfile.lock | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/Gemfile b/Gemfile index c6a43fd..826e4ab 100644 --- a/Gemfile +++ b/Gemfile @@ -56,3 +56,7 @@ gem 'kaminari' # for JSON response serialization gem 'jsonapi-serializer' + +group :test do + gem 'database_cleaner-active_record' +end diff --git a/Gemfile.lock b/Gemfile.lock index 6fae23e..52f95bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,10 @@ GEM concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) + database_cleaner-active_record (2.2.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) date (3.3.4) debug (1.9.2) irb (~> 1.10) @@ -291,6 +295,7 @@ PLATFORMS DEPENDENCIES bootsnap brakeman + database_cleaner-active_record debug dotenv-rails factory_bot_rails From 28c439ddaf0be9451375ad5bd21b1daf1da8efa9 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:11:17 +0000 Subject: [PATCH 26/52] refactor: Update application_controller.rb to handle validation error --- app/controllers/application_controller.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0be417d..59b19b2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -78,7 +78,6 @@ def internal_server_error(error) logger.error "#{error.class.name}: #{error.message}" render_error( error: 'Internal Server Error', - # details: { exception: error.class.name, message: error.message }, status: :internal_server_error ) end @@ -87,7 +86,7 @@ def internal_server_error(error) def validation_error(error) render_error( error: 'Validation Failed', - details: error.record.errors.full_messages.to_sentence, + details: error.record.errors.to_hash(full_messages: true), status: :unprocessable_content ) end From 97689a6c060b873148ac78cbf61429658b722fdb Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:12:49 +0000 Subject: [PATCH 27/52] refactor: Update developer token and user ID retrieval --- app/controllers/concerns/authentication.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index c9a639a..475888f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -37,11 +37,14 @@ def skip_user_id_verification? def verify_developer_token! return if valid_developer_token? + # TODO: return the cached developer ID after it has been validated + render_error( error: 'Authorization failed', details: { error: 'Invalid developer token', - message: 'Please provide a valid developer token' + message: 'Please provide a valid developer token in the header. ' \ + 'E.g., X-Developer-Token: ' }, status: :unauthorized ) @@ -56,7 +59,7 @@ def verify_user_id! error: 'Authorization failed', details: { error: 'Invalid user ID', - message: 'Please provide a valid user ID' + message: 'Please provide a valid user ID. E.g., X-User-Id: ' }, status: :unauthorized ) From 690190b179c4f5e40dc6a106ffa94bc590729a69 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:13:53 +0000 Subject: [PATCH 28/52] refactor: Add status code success metadata for successful JSON responses --- app/controllers/concerns/json_response.rb | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/controllers/concerns/json_response.rb b/app/controllers/concerns/json_response.rb index fb7623f..2a19f19 100644 --- a/app/controllers/concerns/json_response.rb +++ b/app/controllers/concerns/json_response.rb @@ -16,12 +16,22 @@ module JsonResponse # metadata (default: 'Request successful'). # @param extra_meta [Hash] Additional metadata to include in the response # (default: {}). - def json_response(resource, serializer:, message: 'Request successful', - extra_meta: {}) + def json_response( + resource, serializer:, message: 'Request successful', + extra_meta: {} + ) + + metadata = { + status_code:, + success: status_code < 400 + } + + metadata.merge(extra_meta) + if paginated?(resource) - render_paginated(resource, message:, extra_meta:, serializer:) + render_paginated(resource, message:, extra_meta: metadata, serializer:) else - render_single(resource, message:, extra_meta:, serializer:) + render_single(resource, message:, extra_meta: metadata, serializer:) end end @@ -64,10 +74,14 @@ def render_paginated(resource, message:, extra_meta:, serializer:) # @param extra_meta [Hash] Additional metadata to include in the response. # @return [Hash] The complete JSON response with data and meta. def render_single(resource, message:, extra_meta:, serializer:) - meta = { - message: - }.merge(extra_meta) + meta = { message: }.merge(extra_meta) serializer.new(resource).serializable_hash.merge(meta:) end + + def status_code + return 201 if response.request.method == 'POST' + + 200 + end end From cfa2ca32a2dc1fcfef7502f24d34823d3800d771 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:18:00 +0000 Subject: [PATCH 29/52] refactor: Update to mock the valid_developer_token to return true always --- spec/requests/api/v1/categories_spec.rb | 113 ++++++++++++++++-------- 1 file changed, 78 insertions(+), 35 deletions(-) diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index b340add..64b90f4 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -31,6 +31,12 @@ end context 'with valid developer token' do + before do + # always return true for developer token validation + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:valid_developer_token?).and_return(true) + end + let!(:first_dev_category) do Category.create! valid_attributes.merge( developer_id: first_developer_token @@ -71,32 +77,6 @@ expect(meta).to include(:message, :total_count, :current_count) end - describe 'Authorization' do - it 'rejects requests without a developer token' do - get api_v1_categories_url - expect(response).to have_http_status(:unauthorized) - end - - it 'rejects requests with an invalid developer token' do - allow_any_instance_of(Api::V1::CategoriesController).to \ - receive(:valid_developer_token?).and_return(false) - - get api_v1_categories_url, - headers: { 'X-Developer-Token' => UUID7.generate } - - expect(response).to have_http_status(:unauthorized) - end - - it 'permits developers with valid tokens in the headers' do - allow_any_instance_of(Api::V1::CategoriesController).to \ - receive(:valid_developer_token?).and_return(true) - - get api_v1_categories_url, - headers: first_dev_headers - expect(response).to have_http_status(:ok) - end - end - describe 'Pagination' do it 'contains pagination links' do get api_v1_categories_url, headers: first_dev_headers @@ -535,39 +515,102 @@ end end end + end + + context 'with an invalid or missing developer token' do + before do + # always return false for developer token validation + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:valid_developer_token?).and_return(false) + end - context 'with an invalid or missing developer token' do - it 'returns a 401 on the /index route' do + describe '/index' do + it 'returns a 401 with no dev token' do get api_v1_categories_url expect(response).to have_http_status(:unauthorized) end - it 'returns a 401 on the /create route' do - post api_v1_categories_url + it 'returns 401 for invalid developer token' do + get api_v1_categories_url, + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + end + end + + describe '/create' do + it 'returns a 401 when developer token is not provided' do + post api_v1_categories_url, params: { category: valid_attributes } + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 401 for invalid developer token' do + post api_v1_categories_url, + params: { category: valid_attributes }, + headers: { 'X-Developer-Token': UUID7.generate } expect(response).to have_http_status(:unauthorized) end + end - it 'returns a 401 on the /show route' do + describe '/show' do + it 'returns a 401 with no dev token' do get api_v1_category_url(UUID7.generate) expect(response).to have_http_status(:unauthorized) end - it 'returns a 401 on the /update (PUT | PATCH) route' do - put api_v1_category_url(UUID7.generate) + it 'returns 401 for invalid developer token' do + get api_v1_category_url(UUID7.generate), + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + end + end + + describe '/update (PUT | PATCH)' do + it 'returns a 401 when developer token is not provided' do + put api_v1_category_url(UUID7.generate), + params: { category: valid_attributes } + expect(response).to have_http_status(:unauthorized) - patch api_v1_category_url(UUID7.generate) + patch api_v1_category_url(UUID7.generate), + params: { category: valid_attributes } + expect(response).to have_http_status(:unauthorized) end - it 'returns a 401 on the /destroy route' do + it 'returns 401 for invalid developer token' do + put api_v1_categories_url, + params: { category: valid_attributes }, + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + + patch api_v1_categories_url, + params: { category: valid_attributes }, + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + end + end + + describe '/destroy' do + it 'returns a 401 with no dev token' do delete api_v1_category_url(UUID7.generate) expect(response).to have_http_status(:unauthorized) end + + it 'returns 401 for invalid developer token' do + delete api_v1_category_url(UUID7.generate), + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + end end end end From 3d4bce2714f5f4332dfb77a1f9ca7fe110f40ef7 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:09:16 +0000 Subject: [PATCH 30/52] feat: Add redis and HTTParty --- Gemfile | 4 +++- Gemfile.lock | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 826e4ab..99d8767 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ gem 'pg', '~> 1.1' # Use the Puma web server [https://github.com/puma/puma] gem 'puma', '>= 5.0' # Use Redis adapter to run Action Cable in production -# gem "redis", ">= 4.0.1" +gem 'redis', '>= 4.0.1' # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" @@ -60,3 +60,5 @@ gem 'jsonapi-serializer' group :test do gem 'database_cleaner-active_record' end + +gem 'httparty', '~> 0.22.0' diff --git a/Gemfile.lock b/Gemfile.lock index 52f95bf..7ae65fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,7 @@ GEM concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) + csv (3.3.0) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) @@ -105,6 +106,10 @@ GEM railties (>= 5.0.0) globalid (1.2.1) activesupport (>= 6.1) + httparty (0.22.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) i18n (1.14.5) concurrent-ruby (~> 1.0) io-console (0.7.2) @@ -140,6 +145,8 @@ GEM mini_mime (1.1.5) minitest (5.25.1) msgpack (1.7.2) + multi_xml (0.7.1) + bigdecimal (~> 3.1) net-imap (0.4.16) date net-protocol @@ -213,6 +220,10 @@ GEM rake (13.2.1) rdoc (6.7.0) psych (>= 4.0.0) + redis (5.3.0) + redis-client (>= 0.22.0) + redis-client (0.22.2) + connection_pool regexp_parser (2.9.2) reline (0.5.10) io-console (~> 0.5) @@ -299,11 +310,13 @@ DEPENDENCIES debug dotenv-rails factory_bot_rails + httparty (~> 0.22.0) jsonapi-serializer kaminari pg (~> 1.1) puma (>= 5.0) rails (~> 7.2.1) + redis (>= 4.0.1) rspec-rails (~> 7.0) rubocop-rails-omakase simplecov From 78de640916795e456f910013222a05cbe6c91065 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:11:27 +0000 Subject: [PATCH 31/52] test: Fix test cases for categories_controller.rb --- spec/requests/api/v1/categories_spec.rb | 247 +++++++++++++----------- 1 file changed, 135 insertions(+), 112 deletions(-) diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 64b90f4..e491d64 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -1,54 +1,39 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/shared_contexts' RSpec.describe "/api/v1/categories", type: :request do - let(:valid_attributes) do - { name: 'Home Appliance', - description: 'Everything home appliance goes here' } - end + include_context 'common data' - let(:first_developer_token) { UUID7.generate } - let(:second_developer_token) { UUID7.generate } + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(nil) - let(:invalid_attributes) do - { name: '', description: '' } # no category name and description + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(nil) end - # initial validation validation is done for the first developer token - let!(:first_dev_headers) do + let(:valid_attributes) do { - 'Content-Type' => 'application/json', - 'X-Developer-Token' => first_developer_token + name: 'Home Appliance', + description: 'Everything home appliance goes here' } end - let!(:second_dev_headers) do - { - 'Content-Type' => 'application/json', - 'X-Developer-Token' => second_developer_token - } + let(:invalid_attributes) do + { name: '', description: '' } # no category name and description end - context 'with valid developer token' do - before do - # always return true for developer token validation - allow_any_instance_of(Api::V1::CategoriesController).to \ - receive(:valid_developer_token?).and_return(true) - end + context 'with valid developer token: using first dev' do + let(:expected_developer_id) { developers.dig(:first, :id) } - let!(:first_dev_category) do - Category.create! valid_attributes.merge( - developer_id: first_developer_token - ) - end + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(expected_developer_id) - let!(:second_dev_category) do - Category.create!( - name: 'Computer Accessories', - description: 'Contains products such as keyboards mice, etc.', - developer_id: second_developer_token - ) + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(expected_developer_id) end # ensure that there are two categories at the start of the test @@ -58,17 +43,25 @@ describe "GET /index" do it "renders a successful response" do - get api_v1_categories_url, headers: first_dev_headers + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to be_successful end + it 'gets the right developer ID from the user service' do + get api_v1_categories_url, headers: valid_headers[:first_dev] + + data = response_body[:data][0] + expect(data.dig(:attributes, :developer_id)).to \ + eq(expected_developer_id) + end + it "contains data object for actual response data" do - get api_v1_categories_url, headers: first_dev_headers - expect(response_body).to include(:data) + get api_v1_categories_url, headers: valid_headers[:first_dev] + expect(response_body).to have_key(:data) end it 'contains RESTful meta information' do - get api_v1_categories_url, headers: first_dev_headers + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body).to have_key(:meta) @@ -79,15 +72,20 @@ describe 'Pagination' do it 'contains pagination links' do - get api_v1_categories_url, headers: first_dev_headers + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body).to have_key(:links) pagination_links = response_body[:links] - expect(pagination_links).to include(:first, :last, :prev, :next) + expect(pagination_links).to have_key(:first) + expect(pagination_links).to have_key(:last) + expect(pagination_links).to have_key(:prev) + expect(pagination_links).to have_key(:next) + # let's validate that the pagination links are correct and contains + # the right keys expect(pagination_links[:prev]).to eq(nil) expect(pagination_links[:next]).to eq(nil) @@ -101,7 +99,7 @@ it 'responds to page_size query parameter' do get api_v1_categories_url, params: { page_size: 1 }, - headers: first_dev_headers + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) @@ -113,7 +111,7 @@ it 'limits the page size to 100' do get api_v1_categories_url, params: { page_size: 101 }, - headers: first_dev_headers + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:meta][:current_count]).to be <= 100 @@ -126,8 +124,8 @@ describe 'Response data body' do let!(:categories) { [Category.first, Category.last] } - it 'has all the required and follows the JSON:API standard' do - get api_v1_categories_url, headers: first_dev_headers + it 'has all the required and follows the JSONAPI standard' do + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data]).to be_an(Array) @@ -151,7 +149,7 @@ end it 'has the same data as the ones stored in the database' do - get api_v1_categories_url, headers: first_dev_headers + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) response_data = response_body[:data] @@ -173,14 +171,16 @@ end it 'contains only the resources owned by the authenticated developer' do - get api_v1_categories_url, headers: first_dev_headers + get api_v1_categories_url, headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) response_data = response_body[:data] + expect(response_data).not_to be_empty + response_data.each do |data| expect(data.dig(:attributes, :developer_id)).to eq( - first_dev_headers['X-Developer-Token'] + developers.dig(:first, :id) ) end end @@ -190,15 +190,15 @@ before do Category.create!( name: 'Body lotions', description: 'Skin care products', - developer_id: first_developer_token - ) + developer_id: developers.dig(:first, :id) + ).reload end context 'with filters based on the name' do it 'filters the categories based on the name' do get api_v1_categories_url, - params: { name: 'Body lotions' }, - headers: first_dev_headers + params: { name: 'lotions' }, + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data].size).to eq(1) @@ -211,8 +211,8 @@ it 'returns an empty array when no match is found' do get api_v1_categories_url, - params: { name: 'Body lotions' }, - headers: second_dev_headers # second dev does not have this one + params: { name: 'blah' }, + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data].size).to eq(0) @@ -221,7 +221,7 @@ it 'returns all categories when the filter is not found' do get api_v1_categories_url, params: { unknown: 'Body lotions' }, - headers: first_dev_headers + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data].size).to eq(2) @@ -231,8 +231,8 @@ context "with filters based on the 'search' keyword" do it 'returns all categories with the searched keyword' do get api_v1_categories_url, - params: { search: 'home' }, - headers: first_dev_headers + params: { search: 'kitchen' }, + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data].size).to eq(1) @@ -240,7 +240,7 @@ response_data = response_body[:data] expect(response_data.first.dig(:attributes, :name)).to \ - eq('Home Appliance') + eq('Kitchen Appliances') end end end @@ -249,20 +249,20 @@ describe "GET /show" do context "with the first developer's token" do it "renders a successful response" do - get api_v1_category_url(first_dev_category), - headers: first_dev_headers + get api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:first_dev] expect(response).to be_successful end it "contains data object for actual response data" do - get api_v1_category_url(first_dev_category), - headers: first_dev_headers + get api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:first_dev] expect(response_body).to include(:data) end it "contains the exact data for the category requested" do - get api_v1_category_url(first_dev_category), - headers: first_dev_headers + get api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:first_dev] expect(response).to have_http_status(:ok) expect(response_body[:data]).to include(:id, :type, :attributes) @@ -270,45 +270,65 @@ response_data = response_body[:data] expect(response_data.dig(:attributes, :name)).to eq( - first_dev_category.name + first_dev_category_kitchen.name ) expect(response_data.dig(:attributes, :description)).to eq( - first_dev_category.description + first_dev_category_kitchen.description ) expect(response_data.dig(:attributes, :developer_id)).to eq( - first_dev_category.developer_id + first_dev_category_kitchen.developer_id ) end it "gets a 404 when the category is not found" do - get api_v1_category_url(UUID7.generate), headers: first_dev_headers + get api_v1_category_url(UUID7.generate), + headers: valid_headers[:first_dev] expect(response).to have_http_status(:not_found) end it "rejects requests from developers who do not own the category" do - get api_v1_category_url(first_dev_category), - headers: second_dev_headers + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return( + developers.dig(:second, :id) + ) + + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(developers.dig(:second, :id)) + + get api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:second_dev] expect(response).to have_http_status(:not_found) end end context "with the second developer's token" do + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return( + developers.dig(:second, :id) + ) + + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(developers.dig(:second, :id)) + end + it "renders a successful response" do - get api_v1_category_url(second_dev_category), - headers: second_dev_headers + get api_v1_category_url(second_dev_category_computers), + headers: valid_headers[:second_dev] + expect(response).to be_successful end it "contains data object for actual response data" do - get api_v1_category_url(second_dev_category), - headers: second_dev_headers + get api_v1_category_url(second_dev_category_computers), + headers: valid_headers[:second_dev] expect(response_body).to include(:data) end it "contains the exact data for the category requested" do - get api_v1_category_url(second_dev_category), - headers: second_dev_headers + get api_v1_category_url(second_dev_category_computers), + headers: valid_headers[:second_dev] expect(response).to have_http_status(:ok) expect(response_body[:data]).to include(:id, :type, :attributes) @@ -316,24 +336,28 @@ response_data = response_body[:data] expect(response_data.dig(:attributes, :name)).to eq( - second_dev_category.name + second_dev_category_computers.name ) expect(response_data.dig(:attributes, :description)).to eq( - second_dev_category.description + second_dev_category_computers.description ) expect(response_data.dig(:attributes, :developer_id)).to eq( - second_dev_category.developer_id + second_dev_category_computers.developer_id ) end it "gets a 404 when the category is not found" do - get api_v1_category_url(UUID7.generate), headers: second_dev_headers + get api_v1_category_url(UUID7.generate), + headers: valid_headers[:second_dev] expect(response).to have_http_status(:not_found) end it "rejects requests from developers who do not own the category" do - get api_v1_category_url(second_dev_category), - headers: first_dev_headers + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(developers.dig(:first, :id)) + + get api_v1_category_url(second_dev_category_computers), + headers: valid_headers[:first_dev] expect(response).to have_http_status(:not_found) end @@ -350,7 +374,7 @@ description: 'This category is for skin care products' } }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json end.to change(Category, :count).by(1) expect(response).to have_http_status(:created) @@ -374,11 +398,11 @@ post api_v1_categories_url, params: { category: { - name: first_dev_category.name, - description: first_dev_category.description + name: first_dev_category_kitchen.name, + description: first_dev_category_kitchen.description } }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json end.not_to change(Category, :count) expect(response).to have_http_status(:conflict) @@ -394,7 +418,7 @@ description: 'This category is for skin care products' } }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:created) expect(response.content_type).to include("application/json") @@ -405,7 +429,7 @@ it "does not create a new Category" do expect do post api_v1_categories_url, - headers: first_dev_headers, + headers: valid_headers[:first_dev], params: { api_v1_category: invalid_attributes }, as: :json end.to change(Category, :count).by(0) end @@ -413,7 +437,7 @@ it "renders a JSON response with errors for the new category" do post api_v1_categories_url, params: { category: invalid_attributes }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:unprocessable_content) expect(response.content_type).to include("application/json") end @@ -430,32 +454,35 @@ end it "updates the requested category" do - patch api_v1_category_url(first_dev_category), + patch api_v1_category_url(first_dev_category_kitchen), params: { category: new_attributes }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json - first_dev_category.reload + first_dev_category_kitchen.reload expect(response).to have_http_status(:ok) - expect(first_dev_category.description).to eq( + expect(first_dev_category_kitchen.description).to eq( new_attributes[:description] ) - expect(first_dev_category.name).to eq(new_attributes[:name]) + expect(first_dev_category_kitchen.name).to eq(new_attributes[:name]) end it "renders a JSON response with the category" do - patch api_v1_category_url(first_dev_category), + patch api_v1_category_url(first_dev_category_kitchen), params: { category: new_attributes }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") end it "rejects updates from developers who do not own the category" do - patch api_v1_category_url(first_dev_category), + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(developers.dig(:second, :id)) + + patch api_v1_category_url(first_dev_category_kitchen), params: { category: new_attributes }, - headers: second_dev_headers, as: :json + headers: valid_headers[:second_dev], as: :json # for the sake of security, we will just tell them the item wasn't # found because they don't own it. @@ -465,9 +492,9 @@ context "with invalid parameters" do it "renders a JSON response with errors for the category" do - patch api_v1_category_url(first_dev_category), + patch api_v1_category_url(first_dev_category_kitchen), params: { category: invalid_attributes }, - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:unprocessable_content) expect(response.content_type).to include("application/json") @@ -478,8 +505,8 @@ describe "DELETE /destroy" do it "destroys the requested category if it exists" do expect do - delete api_v1_category_url(first_dev_category), - headers: first_dev_headers, as: :json + delete api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:first_dev], as: :json end.to change(Category, :count).by(-1) expect(response).to have_http_status(:no_content) @@ -488,7 +515,7 @@ it 'returns 404 when the requested category is not found' do expect do delete api_v1_category_url(UUID7.generate), - headers: first_dev_headers, as: :json + headers: valid_headers[:first_dev], as: :json end.not_to change(Category, :count) expect(response).to have_http_status(:not_found) @@ -496,8 +523,8 @@ it 'returns an empty response body on success' do expect do - delete api_v1_category_url(first_dev_category), - headers: first_dev_headers, + delete api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:first_dev], as: :json end.to change(Category, :count).by(-1) @@ -506,8 +533,10 @@ end it "rejects deletions from developers who do not own the category" do - delete api_v1_category_url(first_dev_category), - headers: second_dev_headers, as: :json + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(developers.dig(:second, :id)) + delete api_v1_category_url(first_dev_category_kitchen), + headers: valid_headers[:second_dev], as: :json # for the sake of security, we will just tell them the item wasn't # found because they don't own it. @@ -518,12 +547,6 @@ end context 'with an invalid or missing developer token' do - before do - # always return false for developer token validation - allow_any_instance_of(Api::V1::CategoriesController).to \ - receive(:valid_developer_token?).and_return(false) - end - describe '/index' do it 'returns a 401 with no dev token' do get api_v1_categories_url From 62abeffd76cac25a4ce064b40459a5c69b14b0b0 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:15:14 +0000 Subject: [PATCH 32/52] feat: Update schema to include app ID --- app/models/product.rb | 2 +- db/migrate/20240912091234_add_app_id_field_to_products.rb | 7 +++++++ db/schema.rb | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20240912091234_add_app_id_field_to_products.rb diff --git a/app/models/product.rb b/app/models/product.rb index 5abe3d8..9588572 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -6,7 +6,7 @@ class Product < ApplicationRecord belongs_to :category, optional: true - validates :developer_id, :name, :price, :user_id, + validates :developer_id, :name, :price, :user_id, :app_id, :stock_quantity, :description, :currency, presence: true validates :available, inclusion: { in: [true, false] } diff --git a/db/migrate/20240912091234_add_app_id_field_to_products.rb b/db/migrate/20240912091234_add_app_id_field_to_products.rb new file mode 100644 index 0000000..3beb6bd --- /dev/null +++ b/db/migrate/20240912091234_add_app_id_field_to_products.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAppIdFieldToProducts < ActiveRecord::Migration[7.2] + def change + add_column :products, :app_id, :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index 8026eab..6215d65 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 20_240_908_094_558) do +ActiveRecord::Schema[7.2].define(version: 20_240_912_091_234) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -42,6 +42,7 @@ t.datetime "updated_at", null: false t.boolean "available", default: true, null: false t.string "currency", default: "USD", null: false + t.uuid "app_id" t.index ["category_id"], name: "index_products_on_category_id" t.index %w[name developer_id user_id], name: "index_products_on_name_and_developer_id_and_user_id", unique: true From b169a1590351b2f4ffdccefffa8e8c6a35664ea5 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:17:58 +0000 Subject: [PATCH 33/52] feat: Update authentication.rb to use the user service for validations --- app/controllers/concerns/authentication.rb | 99 +++++++++++++--------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 475888f..27c4861 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Authentication mixin +# app/controllers/concerns/authentication.rb module Authentication extend ActiveSupport::Concern @@ -8,70 +8,91 @@ module Authentication before_action :verify_authentication_credentials! end - # Retrieves the developer's token from the headers - def developer_token - request.headers.fetch('X-Developer-Token', nil) + def developer_id + Rails.cache.fetch("developer:#{developer_token}") + end + + def user_id + request.headers.fetch('X-User-Id', nil) + end + + def app_id + request.headers.fetch('X-App-Id', nil) end private - # Validates both developer token and user ID - def verify_authentication_credentials! - verify_developer_token! + def developer_token + request.headers.fetch('X-Developer-Token', nil) + end + def verify_authentication_credentials! + return unless verify_developer_token! return if skip_user_id_verification? - begin - verify_user_id! - rescue StandardError - nil - end + return unless verify_user_id! + + verify_app_id! end - # Checks if the request is in CategoriesController def skip_user_id_verification? is_a?(Api::V1::CategoriesController) end - # Verifies the developer token + # rubocop:disable Metrics/MethodLength def verify_developer_token! - return if valid_developer_token? - - # TODO: return the cached developer ID after it has been validated - - render_error( - error: 'Authorization failed', - details: { - error: 'Invalid developer token', - message: 'Please provide a valid developer token in the header. ' \ - 'E.g., X-Developer-Token: ' - }, - status: :unauthorized - ) + cached_developer = user_service_client.fetch_developer_id(developer_token) + if cached_developer + true + else + render_error( + error: 'Authorization failed', + details: { + error: 'Invalid developer token', + message: 'Please provide a valid developer token in the header. ' \ + 'E.g., X-Developer-Token: ' + }, + status: :unauthorized + ) + false + end end - # Verifies the user ID def verify_user_id! - user_id = request.headers.fetch('X-User-ID', nil) - return if valid_user_id?(user_id) + cached_user = user_service_client.fetch_user(user_id) + if cached_user + true + else + render_error( + error: 'Authorization failed', + details: { + error: 'Invalid user ID', + message: 'Please provide a valid user ID. ' \ + 'E.g., X-User-Id: ' + }, + status: :unauthorized + ) + false + end + end + + def verify_app_id! + cached_app = user_service_client.fetch_app(app_id) + return if cached_app render_error( error: 'Authorization failed', details: { - error: 'Invalid user ID', - message: 'Please provide a valid user ID. E.g., X-User-Id: ' + error: 'Invalid app ID', + message: 'Please provide a valid app ID. E.g., X-App-Id: ' }, status: :unauthorized ) end - # Placeholder for actual user ID validation logic - def valid_user_id?(user_id) - user_id.present? # Temporary logic - end + # rubocop:enable Metrics/MethodLength - # Placeholder for actual developer token validation logic - def valid_developer_token? - developer_token.present? + def user_service_client + @user_service_client ||= UserServiceClient.new end end From 2cf489c35cbe5cad1339e46a8c24ccddc0607125 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:19:02 +0000 Subject: [PATCH 34/52] refactor: Improve pagination response --- app/controllers/concerns/pagination_helper.rb | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/app/controllers/concerns/pagination_helper.rb b/app/controllers/concerns/pagination_helper.rb index d4296d3..ebeeee7 100644 --- a/app/controllers/concerns/pagination_helper.rb +++ b/app/controllers/concerns/pagination_helper.rb @@ -2,29 +2,44 @@ # Helper for API response pagination module PaginationHelper - # rubocop:disable Metrics/MethodLength + extend ActiveSupport::Concern + + included do + class_attribute :max_pagination_size + self.max_pagination_size = 100 + end + + # Determines the page size for pagination, ensuring it does not exceed + # the maximum limit + def page_size + [ + params.fetch(:page_size, self.class.max_pagination_size).to_i, + self.class.max_pagination_size + ].min + end def paginate(resource, message: 'Records retrieved successfully') page = resource.current_page - total_pages = resource.total_pages + total_pages = [resource.total_pages, 1].max page_size = resource.limit_value - links = { - first: url_for(page: 1, page_size:), - last: url_for(page: total_pages, page_size:), - prev: (url_for(page: page - 1, page_size:) if page > 1), - next: (url_for(page: page + 1, page_size:) if page < total_pages) - } - { meta: { total_count: resource.total_count, - current_count: resource.count, - message: + current_count: resource.count, message: }, - links: + links: create_pagination_links(page, total_pages, page_size) } end -end -# rubocop:enable Metrics/MethodLength + private + + def create_pagination_links(page, total_pages, page_size) + { + first: url_for(page: 1, page_size:), + last: url_for(page: total_pages, page_size:), + prev: (url_for(page: page - 1, page_size:) if page > 1), + next: (url_for(page: page + 1, page_size:) if page < total_pages) + } + end +end From df0609a9291967fbdd5e8e3ec9f87e006ae71f26 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:19:39 +0000 Subject: [PATCH 35/52] feat: Implement a method to handle bad requests. --- app/controllers/application_controller.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 59b19b2..1697c20 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,7 @@ class ApplicationController < ActionController::API rescue_from ActiveRecord::RecordInvalid, with: :validation_error rescue_from NoMethodError, NameError, with: :internal_server_error rescue_from ActionController::RoutingError, with: :invalid_route + rescue_from ActionController::ParameterMissing, with: :bad_request # rubocop:disable Metrics/MethodLength @@ -90,4 +91,12 @@ def validation_error(error) status: :unprocessable_content ) end + + def bad_request(error) + render_error( + error: 'Bad Request', + details: error.message, + status: :bad_request + ) + end end From a04d2328ce2f554cc725fa5ce3d6bdd3d98067e2 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:20:14 +0000 Subject: [PATCH 36/52] feat: Add Redis as the caching store --- config/application.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/application.rb b/config/application.rb index 4ae8b4f..1f6818d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -48,5 +48,7 @@ class Application < Rails::Application config.generators do |generate| generate.orm :active_record, primary_key_type: :uuid end + + config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } end end From 5c7441e9df9fe4f47c588dbd39d8963fb834ad29 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:24:01 +0000 Subject: [PATCH 37/52] feat: Add caching for single results --- .../api/v1/categories_controller.rb | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 7cf6fbc..9b68e76 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -5,9 +5,6 @@ module V1 # Controller for version of Categories side of Product Management Service # This controller handles CRUD operations for categories. class CategoriesController < ApplicationController - # Maximum number of items to be returned in a single page - MAX_PAGINATION_SIZE = 100 - # Before actions to set up authorization and category object before_action :set_api_v1_category, only: %i[show update destroy] @@ -15,20 +12,20 @@ class CategoriesController < ApplicationController # GET /api/v1/categories.json # Retrieves a paginated list of categories filtered by developer token def index - # Fetch categories with pagination - categories = Category.page(params.fetch(:page, 1)).per(page_size) + categories = Category.all categories = perform_filtering(categories) - # Render the JSON response with the categories + paginated_categories = categories.page(params[:page]).per(page_size) + render json: json_response( - categories, serializer:, - message: 'Categories retrieved successfully' + paginated_categories, serializer:, + message: 'Categories retrieved successfully' ) end - # GET /api/v1/categories/1 - # GET /api/v1/categories/1.json + # GET /api/v1/categories/:id + # GET /api/v1/categories/:id.json # Retrieves a specific category by ID def show # Render the JSON response with the category @@ -52,11 +49,11 @@ def create serializer:), status: :created end - # PATCH/PUT /api/v1/categories/1 - # PATCH/PUT /api/v1/categories/1.json + # PATCH/PUT /api/v1/categories/:id + # PATCH/PUT /api/v1/categories/:id.json # Updates an existing category def update - if @category.update(api_v1_category_params) + if @category.update!(api_v1_category_params) # Render the JSON response with the updated category render json: json_response(@category, serializer:, @@ -67,8 +64,9 @@ def update end end - # DELETE /api/v1/categories/1 - # DELETE /api/v1/categories/1.json + # DELETE /api/v1/categories/:id + # DELETE /api/v1/categories/:id.json + # # Deletes a specific category by ID def destroy @category.destroy! @@ -81,22 +79,16 @@ def destroy # Reader method for the category instance variable attr_reader :category - # Determines the page size for pagination, ensuring it does not exceed - # the maximum limit - def page_size - [ - params.fetch(:page_size, MAX_PAGINATION_SIZE).to_i, - MAX_PAGINATION_SIZE - ].min - end - # Sets the category instance variable based on the ID and developer # token def set_api_v1_category - @category = Category.find_by!( - id: params[:id], - developer_id: developer_token - ) + cache_key = "category/#{params[:id]}_#{developer_id}" + @category = Rails.cache.fetch(cache_key, expires_in: 12.hours) do + Category.find_by!( + id: params[:id], + developer_id: + ) + end end # Only allow a list of trusted parameters through. @@ -105,7 +97,7 @@ def set_api_v1_category def api_v1_category_params params.require(:category) .permit(:name, :description) - .merge(developer_id: developer_token) + .merge(developer_id:) end # Returns the serializer class for the category @@ -115,7 +107,7 @@ def serializer def perform_filtering(categories) # Filter categories by developer token - categories = categories.where(developer_id: developer_token) + categories = categories.where(developer_id:) # Filter categories by name if params[:name].present? From 054b0c1576ce9a6a73a3a2308b7edaedee8bc8d7 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:26:16 +0000 Subject: [PATCH 38/52] test: Add tests for route matching for products API endpoints --- spec/routing/api/v1/products_routing_spec.rb | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 spec/routing/api/v1/products_routing_spec.rb diff --git a/spec/routing/api/v1/products_routing_spec.rb b/spec/routing/api/v1/products_routing_spec.rb new file mode 100644 index 0000000..dc64239 --- /dev/null +++ b/spec/routing/api/v1/products_routing_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::ProductsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/api/v1/products").to route_to("api/v1/products#index") + end + + it "routes to #show" do + expect(get: "/api/v1/products/1").to route_to("api/v1/products#show", + id: "1") + end + + it "routes to #create" do + expect(post: "/api/v1/products").to route_to("api/v1/products#create") + end + + it "routes to #update via PUT" do + expect(put: "/api/v1/products/1").to route_to("api/v1/products#update", + id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/api/v1/products/1").to route_to("api/v1/products#update", + id: "1") + end + + it "routes to #destroy" do + expect(delete: "/api/v1/products/1").to route_to( + "api/v1/products#destroy", id: "1" + ) + end + end +end From c666d4bdf034e6850ab355849cefd3a0379569f1 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:27:57 +0000 Subject: [PATCH 39/52] feat: Implement user service client to validate user, app and developer details --- app/services/user_service_client.rb | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/services/user_service_client.rb diff --git a/app/services/user_service_client.rb b/app/services/user_service_client.rb new file mode 100644 index 0000000..e34424e --- /dev/null +++ b/app/services/user_service_client.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Service class to handle API requests to the user service and manage caching. +class UserServiceClient + include HTTParty + base_uri ENV['USER_SERVICE_URL'] + + # rubocop:disable Metrics/MethodLength + + # Fetches and caches the developer data based on the developer token. + # + # @param developer_token [String] the developer token to validate + # @return [String, nil] the developer ID if valid, nil otherwise + def fetch_developer_id(developer_token) + dev_token_key = "developer:#{developer_token}" + cached_data = Rails.cache.fetch(dev_token_key) + return cached_data if cached_data + + response = self.class.get( + '/validate_developer', + headers: { 'X-Developer-Token' => developer_token } + ) + + return unless response.success? + + Rails.cache.fetch(dev_token_key, expires_in: 12.hours) do + response.parsed_response['developer_id'] + end + end + + # rubocop:enable Metrics/MethodLength + + # Fetches and caches the user data based on the user ID. + # + # @param user_id [String] the user ID to validate + # @return [Hash, nil] the user data if valid, nil otherwise + def fetch_user(user_id) + Rails.cache.fetch("user:#{user_id}", expires_in: 12.hours) do + response = self.class.get("/validate_user/#{user_id}") + return response.parsed_response if response.success? + + nil + end + end + + # Fetches and caches the app data based on the app ID. + # + # @param app_id [String] the app ID to validate + # @return [Hash, nil] the app data if valid, nil otherwise + def fetch_app(app_id) + Rails.cache.fetch("app:#{app_id}", expires_in: 12.hours) do + response = self.class.get("/validate_app/#{app_id}") + return response.parsed_response if response.success? + + nil + end + end +end From 0448f0d93844a60c41b4aa1a026c914bd965614c Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:28:48 +0000 Subject: [PATCH 40/52] test: Add tests for UserServiceClient --- spec/services/user_services_client_spec.rb | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 spec/services/user_services_client_spec.rb diff --git a/spec/services/user_services_client_spec.rb b/spec/services/user_services_client_spec.rb new file mode 100644 index 0000000..1f22f3e --- /dev/null +++ b/spec/services/user_services_client_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserServiceClient, type: :service do + let(:developer_token) { 'some_developer_token' } + let(:developer_id) { 'some_developer_id' } + let(:user_service_client) { UserServiceClient.new } + + describe '#fetch_developer_id' do + it 'returns the cached developer ID if present' do + allow(Rails.cache).to \ + receive(:fetch).with( + "developer:#{developer_token}" + ).and_return(developer_id) + + result = user_service_client.fetch_developer_id(developer_token) + expect(result).to eq(developer_id) + end + + it 'fetches and caches the developer ID if not cached' do + allow(Rails.cache).to receive(:fetch).with( + "developer:#{developer_token}" + ).and_return(nil) + allow(user_service_client.class).to \ + receive(:get).and_return( + double( + success?: true, + parsed_response: { developer_id: } + ) + ) + allow(Rails.cache).to receive(:fetch).with( + "developer:#{developer_token}", expires_in: 12.hours + ).and_yield.and_return(developer_id) + + result = user_service_client.fetch_developer_id(developer_token) + expect(result).to eq(developer_id) + end + + it 'returns nil if the response is not successful' do + allow(Rails.cache).to \ + receive(:fetch).with("developer:#{developer_token}").and_return(nil) + allow(user_service_client.class).to \ + receive(:get).and_return(double(success?: false)) + + result = user_service_client.fetch_developer_id(developer_token) + expect(result).to be_nil + end + end +end From 9f692731b445dea038cf7e9a0734200a202965a8 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:30:28 +0000 Subject: [PATCH 41/52] feat: Add database_cleaner and shared_context.rb files --- spec/support/database_cleaner.rb | 9 +++++ spec/support/shared_contexts.rb | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 spec/support/database_cleaner.rb create mode 100644 spec/support/shared_contexts.rb diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb new file mode 100644 index 0000000..1d5171e --- /dev/null +++ b/spec/support/database_cleaner.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'database_cleaner/active_record' + +RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.clean + end +end diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb new file mode 100644 index 0000000..f839cb8 --- /dev/null +++ b/spec/support/shared_contexts.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.shared_context 'common data' do + let!(:developers) do + { + first: { + id: '0191effd-5782-713e-8134-a59129cd7a87', + app_id: '0191effd-b349-7740-8728-c9056f008398', + api_token: '0191effd-e9b7-7cd2-89b9-c518b2be0f7a' + }, + second: { + id: '0191effe-0aa2-7430-ad59-f0a19b012032', + app_id: '0191effe-4558-7a4d-8411-8cf503eb7558', + api_token: '0191effe-72b1-7b2f-80b6-840ad64bb233' + } + } + end + + let!(:users) do + { + one: { + id: '0191effe-d200-7a69-885c-d38cf8dd855b', + app_id: developers.dig(:one, :id) + }, + two: { + id: '0191efff-0a87-7850-9096-cbbf17145710', + app_id: developers.dig(:two, :id) + } + } + end + + let!(:valid_headers) do + { + first_dev: { + 'X-Developer-Token' => developers.dig(:first, :api_token), + 'X-User-Id' => users.dig(:one, :id), + 'X-App-Id' => developers.dig(:first, :app_id) + }, + second_dev: { + 'X-Developer-Token' => developers.dig(:first, :api_token), + 'X-User-Id' => users.dig(:two, :id), + 'X-App-Id' => developers.dig(:second, :app_id) + } + } + end + + let!(:first_dev_category_kitchen) do + Category.create!(name: 'Kitchen Appliances', + description: 'Products that are to used in the kitchen', + developer_id: developers.dig(:first, :id)) + end + + let!(:second_dev_category_computers) do + Category.create!(name: 'Computers', + description: 'Products that are to used for computing', + developer_id: developers.dig(:second, :id)) + end +end From ad798c9fba94f64ceb673f9dcc368ecc98816d87 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Sat, 14 Sep 2024 14:11:15 +0000 Subject: [PATCH 42/52] feat: Fix failing tests after adding mandatory app_id field --- spec/models/category_spec.rb | 7 ++++--- spec/models/product_spec.rb | 28 ++++++++++++++++++---------- spec/requests_helper.rb | 7 ------- spec/support/requests_helper.rb | 11 +++++++++++ 4 files changed, 33 insertions(+), 20 deletions(-) delete mode 100644 spec/requests_helper.rb create mode 100644 spec/support/requests_helper.rb diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 67c6f4c..8af6e2a 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -203,10 +203,11 @@ expect do Product.create!( name: 'Binatone 3 in 1 Blender', developer_id:, - category_id: home_appliance.id, price: 100, user_id: UUID7.generate, - stock_quantity: 10, + category_id: home_appliance.id, price: 100, + user_id: UUID7.generate, stock_quantity: 10, description: 'This is an amazing blender for all your ' \ - 'cooking needs in household.' + 'cooking needs in household.', + app_id: UUID7.generate ) end.to_not raise_error diff --git a/spec/models/product_spec.rb b/spec/models/product_spec.rb index b1fda64..f2149f0 100644 --- a/spec/models/product_spec.rb +++ b/spec/models/product_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Product, type: :model do let(:developer_id) { UUID7.generate } let(:user_id) { UUID7.generate } + let(:app_id) { UUID7.generate } let(:category) do FactoryBot.create(:category, name: 'Home Appliances', description: 'Home appliance for user needs', @@ -13,9 +14,12 @@ it 'throws an error when creating with a nil category_id' do expect do - Product.create!(name: 'Laptop cases', developer_id:, category_id: nil, - price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops') + Product.create!( + name: 'Laptop cases', developer_id:, category_id: nil, + price: 100, user_id:, stock_quantity: 10, + description: 'A case for laptops', + app_id: UUID7.generate + ) end.to raise_error(ActiveRecord::RecordInvalid) end @@ -32,7 +36,8 @@ product = Product.new(name: 'Laptop cases', developer_id:, category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops' * 3) + description: 'A case for laptops' * 3, + app_id: UUID7.generate) expect(product).to be_valid @@ -90,7 +95,7 @@ Product.create!(name: 'Laptop cases', developer_id:, category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops' * 3) + description: 'A case for laptops' * 3, app_id:) end.to_not raise_error product = Product.first @@ -101,7 +106,7 @@ Product.create!(name: 'Laptop cases', developer_id:, category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops' * 3) + description: 'A case for laptops' * 3, app_id:) end.to raise_error(ActiveRecord::RecordNotUnique) expect(Product.count).to eq(1) @@ -115,7 +120,8 @@ Product.create!(name: 'Laptop cases', developer_id:, category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops' * 3) + description: 'A case for laptops' * 3, + app_id:) end.to_not raise_error expect(Product.count).to eq(1) @@ -125,7 +131,7 @@ category_id: category.id, price: 100, user_id: second_user_id, stock_quantity: 10, - description: 'A case for laptops' * 3) + description: 'A case for laptops' * 3, app_id:) end.to_not raise_error expect(Product.count).to eq(2) @@ -144,7 +150,8 @@ category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A washing machine for user needs' * 4) + description: 'A washing machine for user needs' * 4, + app_id:) end it 'allows deletion of a product' do @@ -175,7 +182,8 @@ name: 'Laptop cases', developer_id:, category_id: category.id, price: 100, user_id:, stock_quantity: 10, - description: 'A case for laptops' * 3 + description: 'A case for laptops' * 3, + app_id: ) end diff --git a/spec/requests_helper.rb b/spec/requests_helper.rb deleted file mode 100644 index 39e7194..0000000 --- a/spec/requests_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module RequestsHelper - def response_body - JSON.parse(response.body, symbolize_names: true) - end -end diff --git a/spec/support/requests_helper.rb b/spec/support/requests_helper.rb new file mode 100644 index 0000000..aa3b70e --- /dev/null +++ b/spec/support/requests_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module RequestsHelper + def response_body + JSON.parse(response.body, symbolize_names: true) + end + + # An authentication mocking helper for controller request testing + def authenticate_headers(headers, valid_dev_token: false, + valid_user_id: false, valid_app_id: false) end +end From 2af268cebb10d2824e60d0e2c6bd7890dc4a5533 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:03:34 +0000 Subject: [PATCH 43/52] feat: Add authentication helper for request to spec_helper.rb --- spec/spec_helper.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 62aa7f6..503aaba 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require 'requests_helper' +require 'support/requests_helper' +require 'support/authentication_helper' require 'simplecov' SimpleCov.start 'rails' do @@ -121,4 +122,5 @@ config.shared_context_metadata_behavior = :apply_to_host_groups config.include RequestsHelper, type: :request + config.include AuthenticationHelper, type: :request end From 23f89ffa206d9188db2f25150da1b8a5f399cf77 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:04:21 +0000 Subject: [PATCH 44/52] refactor: Move authentication helper to a separate module --- spec/support/requests_helper.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/support/requests_helper.rb b/spec/support/requests_helper.rb index aa3b70e..39e7194 100644 --- a/spec/support/requests_helper.rb +++ b/spec/support/requests_helper.rb @@ -4,8 +4,4 @@ module RequestsHelper def response_body JSON.parse(response.body, symbolize_names: true) end - - # An authentication mocking helper for controller request testing - def authenticate_headers(headers, valid_dev_token: false, - valid_user_id: false, valid_app_id: false) end end From 138d036fb2180afaa2e711f06874ba3c7365b674 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:05:03 +0000 Subject: [PATCH 45/52] feat: Update shared_contexts.rb to retrieve valid data --- spec/support/shared_contexts.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb index f839cb8..82c0cc8 100644 --- a/spec/support/shared_contexts.rb +++ b/spec/support/shared_contexts.rb @@ -20,11 +20,11 @@ { one: { id: '0191effe-d200-7a69-885c-d38cf8dd855b', - app_id: developers.dig(:one, :id) + app_id: developers.dig(:first, :app_id) }, two: { id: '0191efff-0a87-7850-9096-cbbf17145710', - app_id: developers.dig(:two, :id) + app_id: developers.dig(:first, :app_id) } } end From 3814ae0d4cc9270a868b3b7e9a52e56e8c17df59 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:08:37 +0000 Subject: [PATCH 46/52] style: Rubocop style fixes --- spec/routing/api/v1/products_routing_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/routing/api/v1/products_routing_spec.rb b/spec/routing/api/v1/products_routing_spec.rb index dc64239..e8410c5 100644 --- a/spec/routing/api/v1/products_routing_spec.rb +++ b/spec/routing/api/v1/products_routing_spec.rb @@ -10,7 +10,7 @@ it "routes to #show" do expect(get: "/api/v1/products/1").to route_to("api/v1/products#show", - id: "1") + id: "1") end it "routes to #create" do @@ -19,12 +19,12 @@ it "routes to #update via PUT" do expect(put: "/api/v1/products/1").to route_to("api/v1/products#update", - id: "1") + id: "1") end it "routes to #update via PATCH" do expect(patch: "/api/v1/products/1").to route_to("api/v1/products#update", - id: "1") + id: "1") end it "routes to #destroy" do From 53faf03dae7eb0f750687a1e05bfe12a74d72fc0 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:09:07 +0000 Subject: [PATCH 47/52] feat: Add logic for filtering products --- app/models/product.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/models/product.rb b/app/models/product.rb index 9588572..20f972e 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -4,6 +4,18 @@ class Product < ApplicationRecord include WordCountValidatable + scope :by_name, lambda { |name| + where('name ILIKE ?', "%#{name}%") if name.present? + } + scope :by_category, lambda { |category_id| + where(category_id:) if category_id.present? + } + scope :by_price_range, lambda { |min_price, max_price| + if min_price.present? && max_price.present? + where(price: min_price..max_price) + end + } + belongs_to :category, optional: true validates :developer_id, :name, :price, :user_id, :app_id, From 9dc6e66547ac39d52bea822834de5c37e6670a9e Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:09:54 +0000 Subject: [PATCH 48/52] feat: Add the AuthenticationHelper for request type tests --- spec/support/authentication_helper.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 spec/support/authentication_helper.rb diff --git a/spec/support/authentication_helper.rb b/spec/support/authentication_helper.rb new file mode 100644 index 0000000..2fef6b0 --- /dev/null +++ b/spec/support/authentication_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module AuthenticationHelper + def mock_authentication(controller_class:, developer_id: nil, user_id: nil, + app_id: nil) + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(developer_id) + + allow_any_instance_of(controller_class).to \ + receive(:developer_id).and_return(developer_id) + + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_user).and_return(user_id ? { message: 'Valid user' } : nil) + + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_app).and_return(app_id ? { message: 'Valid app' } : nil) + end +end From 98cb505ad402cdc7712a3dfff3e808754f25747a Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:10:17 +0000 Subject: [PATCH 49/52] test: Add tests for product operation endpoints --- spec/requests/api/v1/products_spec.rb | 631 ++++++++++++++++++++++++++ 1 file changed, 631 insertions(+) create mode 100644 spec/requests/api/v1/products_spec.rb diff --git a/spec/requests/api/v1/products_spec.rb b/spec/requests/api/v1/products_spec.rb new file mode 100644 index 0000000..7dc9728 --- /dev/null +++ b/spec/requests/api/v1/products_spec.rb @@ -0,0 +1,631 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/shared_contexts' + +RSpec.describe "Api::V1::Products", type: :request do + include_context 'common data' + + before do + mock_authentication(controller_class: Api::V1::ProductsController) + end + + let!(:valid_attributes) do + { + product_one: { + name: 'Tea Maker', + description: 'A device for heating food quickly and easily. ' \ + 'It is a kitchen appliance', + price: 100, + stock_quantity: 10, + currency: 'USD', + available: true, + category_id: first_dev_category_kitchen.id + }, + + product_two: { + name: 'Another Kitchen Product', + description: 'A device for cooling food quickly and easily. ' \ + 'It is a kitchen appliance', + price: 200, + stock_quantity: 50, + currency: 'USD', + available: true + } + } + end + + let!(:invalid_attributes) do + { + product_with_no_name: { + description: 'A device for heating food quickly and easily. ' \ + 'It is a kitchen appliance', + price: 100, + stock_quantity: 10 + }, + + product_with_no_price: { + name: 'Refrigerator', + description: 'A device for cooling food quickly and easily. ' \ + 'It is a kitchen appliance', + stock_quantity: 50 + }, + product_with_non_numeric_price: { + name: 'Refrigerator', + description: 'A device for cooling food quickly and easily. ' \ + 'It is a kitchen appliance', + price: '100', + stock_quantity: 50 + }, + product_with_no_app_id: { + name: 'Refrigerator', + description: 'A device for cooling food quickly and easily. ' \ + 'It is a kitchen appliance', + price: '100', + stock_quantity: 50 + } + } + end + + # create 3 products for each developer + before do + Product.create!( + name: 'Microwave', + description: 'A device for heating food quickly and' \ + 'easily. It is a kitchen appliance', + price: 100, + stock_quantity: 10, + currency: 'USD', + available: true, + developer_id: developers.dig(:first, :id), + category_id: first_dev_category_kitchen.id, + app_id: users.dig(:one, :app_id), + user_id: users.dig(:one, :id) + ) + Product.create!( + name: 'Refrigerator', + description: 'A device for cooling food quickly' \ + 'and easily. It is a kitchen appliance', + price: 200, + stock_quantity: 50, + currency: 'USD', + available: true, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:two, :id), + app_id: users.dig(:two, :app_id), + category_id: first_dev_category_kitchen.id + ) + Product.create!( + name: 'Toaster', + description: 'A device for toasting bread quickly and ' \ + 'easily. It is a kitchen appliance', + price: 300, + stock_quantity: 20, + currency: 'USD', + available: true, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:two, :id), + category_id: first_dev_category_kitchen.id, + app_id: users.dig(:two, :app_id) + ) + end + + describe "GET /index" do + context 'with invalid or no authentication details' do + context 'without X-Developer-Token and X-User-Id in the headers' do + it 'returns 401 when not provided' do + get api_v1_products_url + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns the response in a JSON format' do + get api_v1_products_url + + expect(response.content_type).to include('application/json') + end + + it 'contains a message and error stating the token is missing' do + get api_v1_products_url + + expect(response_body.dig(:details, :message)).to \ + include('Please provide a valid developer token') + expect(response_body.dig(:details, :error)).to \ + eq('Invalid developer token') + end + + it 'has the expected response body format' do + get api_v1_products_url + + expect(response_body.keys).to contain_exactly( + :error, :meta, :details + ) + expect(response_body[:error]).to be_a(String) + + expect(response_body[:meta]).to be_a(Hash) + expect(response_body[:meta].keys).to contain_exactly( + :request_path, :request_method, :status_code, :success + ) + + expect(response_body[:details]).to be_a(Hash) + expect(response_body[:details].keys).to contain_exactly( + :error, :message + ) + end + + it 'has the correct error data in the response' do + get api_v1_products_url + + expect(response_body[:error]).to eq('Authorization failed') + + # validate the contents of the meta body + metadata = response_body[:meta] + + expect(metadata[:request_path]).to eq(api_v1_products_url) + expect(metadata[:request_method]).to eq('GET') + expect(metadata[:status_code]).to eq(401) + expect(metadata[:success]).to be(false) + + # validate the details in the details body + details = response_body[:details] + + expect(details[:error]).to eq('Invalid developer token') + expect(details[:message]).to eq( + 'Please provide a valid developer token in the header. ' \ + 'E.g., X-Developer-Token: ' + ) + end + end + + context 'with a X-Developer-Token header but not X-User-Id header' do + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(developers.dig(:first, :id)) + end + + it 'returns a 401' do + get api_v1_products_url, + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response).to have_http_status(:unauthorized) + expect(response_body.dig(:meta, :status_code)).to eq(401) + end + + it 'returns an error message mentioning X-User-Id is missing' do + get api_v1_products_url, + headers: { 'X-Developer-Token': UUID7.generate } + + expect(response_body.dig(:details, :error)).to eq('Invalid user ID') + expect(response_body.dig(:details, :message)).to eq( + 'Please provide a valid user ID. E.g., X-User-Id: ' + ) + end + end + end + + context 'with valid authentication credentials' do + let!(:expected_developer_id) { developers.dig(:first, :id) } + + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + + Rails.cache.clear + + allow(Rails.cache).to receive(:fetch).and_call_original + allow(Product).to receive(:where).and_call_original + end + + it "renders a successful response" do + get api_v1_products_url, headers: valid_headers[:first_dev], as: :json + + expect(response).to be_successful + end + + context 'without filters' do + it 'returns all products' do + get api_v1_products_url, headers: valid_headers[:first_dev] + + expect(response).to have_http_status(:success) + expect(response_body[:data].size).to eq(3) + end + end + + context 'with name filter' do + let!(:filtered_product) do + FactoryBot.create(:product, + name: 'Filtered Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 100, + stock_quantity: 10, + description: 'A filtered case' * 10, + category_id: nil) + end + + it 'returns filtered products by name' do + get api_v1_products_url, headers: valid_headers[:first_dev], + params: { name: 'Filtered Product' } + + expect(response).to have_http_status(:success) + expect(response_body[:data].size).to eq(1) + expect(response_body[:data].first.dig(:attributes, :name)).to \ + eq('Filtered Product') + end + end + + context 'with category filter' do + it 'returns filtered products by category' do + get api_v1_products_url, + headers: valid_headers[:first_dev], + params: { category_id: first_dev_category_kitchen.id } + + expect(response).to have_http_status(:success) + expect(response_body[:data].size).to eq(3) + + # ensure all products returned are in the specified category + response_body[:data].each do |product| + expect(product.dig(:relationships, :category, :data, :id)).to \ + eq(first_dev_category_kitchen.id) + end + end + end + + context 'with price range filter' do + let!(:cheap_price) do + FactoryBot.create(:product, + name: 'Cheap Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 5, + stock_quantity: 10, + description: 'A cheap product ' * 4, + category_id: first_dev_category_kitchen.id) + end + + let!(:expensive_price) do + FactoryBot.create(:product, + name: 'Expensive Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 15, + stock_quantity: 10, + description: 'An expensive product ' * 4, + category_id: first_dev_category_kitchen.id) + end + + it 'returns products within the specified price range' do + get api_v1_products_url, headers: valid_headers[:first_dev], + params: { min_price: 5, max_price: 10 } + + expect(response).to have_http_status(:success) + expect(response_body[:data].size).to eq(1) + expect(response_body[:data].first.dig(:attributes, :name)).to \ + eq('Cheap Product') + expect(response_body[:data].first.dig(:attributes, :price).to_i).to \ + eq(5) + end + end + end + + describe 'caching' do + let!(:expected_developer_id) { developers.dig(:first, :id) } + + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + + Rails.cache.clear + + allow(Rails.cache).to receive(:fetch).and_call_original + allow(Product).to receive(:where).and_call_original + end + + it 'caches the product response' do + get api_v1_products_url, headers: valid_headers[:first_dev] + + expect(response).to have_http_status(:ok) + expect(Rails.cache).to have_received(:fetch) + end + + it 'caches the product response with filters' do + get api_v1_products_url, headers: valid_headers[:first_dev], + params: { name: 'Microwave' } + + expect(response).to have_http_status(:ok) + expect(Rails.cache).to have_received(:fetch) + end + end + end + + describe "GET /show" do + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + end + + let!(:product) do + Product.find_by(developer_id: developers.dig(:first, :id), + app_id: users.dig(:one, :app_id)) + end + + it "renders a successful response" do + get api_v1_product_url(product), headers: valid_headers[:first_dev] + + expect(response).to have_http_status(:ok) + end + + it 'returns the expected response body format' do + get api_v1_product_url(product), headers: valid_headers[:first_dev] + expect(response).to have_http_status(:ok) + + expect(response_body.keys).to contain_exactly( + :data, :meta + ) + expect(response_body[:data]).to be_a(Hash) + expect(response_body[:data].keys).to contain_exactly( + :id, :type, :attributes, :relationships + ) + end + + it 'returns the expected product data' do + get api_v1_product_url(product), headers: valid_headers[:first_dev] + expect(response).to have_http_status(:ok) + + product_data = response_body[:data] + product_attributes = product_data[:attributes] + + expect(product_data[:id]).to eq(product.id) + expect(product_data[:type]).to eq('product') + expect(product_attributes[:name]).to eq(product.name) + expect(product_attributes[:description]).to eq(product.description) + expect(product_attributes[:price].to_f).to eq(product.price.to_f) + expect(product_attributes[:stock_quantity]).to eq(product.stock_quantity) + expect(product_attributes[:currency]).to eq(product.currency) + expect(product_attributes[:available]).to eq(product.available) + end + + context 'errors' do + it 'returns a 404 status code for non-existent products' do + get api_v1_product_url(UUID7.generate), + headers: valid_headers[:first_dev] + + expect(response).to have_http_status(404) + end + + it 'returns the expected response body format for errors' do + get api_v1_product_url(UUID7.generate), + headers: valid_headers[:first_dev] + + expect(response_body.keys).to contain_exactly( + :error, :meta, :details + ) + expect(response_body[:error]).to be_a(String) + + expect(response_body[:meta]).to be_a(Hash) + expect(response_body[:meta].keys).to contain_exactly( + :request_path, :request_method, :status_code, :success + ) + + expect(response_body[:details]).to be_a(Hash) + expect(response_body[:details].keys).to contain_exactly(:message) + end + end + + it 'returns a JSON response' do + get api_v1_product_url(UUID7.generate), + headers: valid_headers[:first_dev] + expect(response.content_type).to include('application/json') + end + end + + describe "POST /create" do + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + end + + context "with valid parameters" do + it "creates a new Product" do + expect do + post api_v1_products_url, + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json + end.to change(Product, :count).by(1) + end + + it 'returns a 201 status code' do + post api_v1_products_url, + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(:created) + end + + it "renders a JSON response with the new product" do + post api_v1_products_url, + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(:created) + expect(response.content_type).to include("application/json") + end + + context "the category_id doesn't belong to the current developer" do + it 'returns a 400: Category does not exist' do + product = valid_attributes[:product_two].merge( + category_id: second_dev_category_computers.id + ) + + post api_v1_products_url, + params: { product: }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(400) + end + + it 'returns a JSON response with the error message' do + product = valid_attributes[:product_two].merge( + category_id: second_dev_category_computers.id + ) + + post api_v1_products_url, + params: { product: }, + headers: valid_headers[:first_dev], as: :json + + expect(response.content_type).to include("application/json") + end + + it 'has the expected error messages' do + product = valid_attributes[:product_two].merge( + category_id: second_dev_category_computers.id + ) + + post api_v1_products_url, + params: { product: }, + headers: valid_headers[:first_dev], as: :json + + expect(response_body.dig(:details, :message)).to \ + include('Verify you have the category you specified') + + expect(response_body[:error]).to eq('Category not found') + end + end + end + + context "with invalid parameters" do + it "does not create a new Product" do + expect do + post api_v1_products_url, + headers: valid_headers[:first_dev], + params: { product: invalid_attributes }, as: :json + end.not_to change(Product, :count) + end + + it "renders a JSON response with errors for the new product" do + post api_v1_products_url, + params: { product: invalid_attributes }, + headers: valid_headers[:first_dev], as: :json + expect(response).to have_http_status(:unprocessable_content) + expect(response.content_type).to include("application/json") + end + + it 'returns a 422 when the product price is not provided' do + post api_v1_products_url, + params: { product: invalid_attributes[:product_with_no_price] }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(422) + end + + it 'returns a 422 when the price is a not a numeric value' do + post api_v1_products_url, + params: { + product: invalid_attributes[:product_with_non_numeric_price] + }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with invalid parameters" do + it "returns a 422 status code for each invalid attribute set" do + invalid_attributes.each do |key, value| + post api_v1_products_url, + params: { product: value }, + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(422), + "Expected 422 for #{key} but got" \ + " #{response.status}" + end + end + end + end + + describe "PATCH /update" do + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + end + + context "with valid parameters" do + let!(:product) { Product.first } + let(:new_attributes) { { name: 'Updated Product name' } } + + it "updates the requested product" do + patch api_v1_product_url(product), + params: { product: new_attributes }, + headers: valid_headers[:first_dev], as: :json + product.reload + expect(product.name).to eq(new_attributes[:name]) + end + + it "renders a JSON response with the product" do + patch api_v1_product_url(product), + params: { product: new_attributes }, + headers: valid_headers[:first_dev], as: :json + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + end + end + end + + describe "DELETE /destroy" do + before do + mock_authentication( + controller_class: Api::V1::ProductsController, + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id) + ) + end + + let(:product) { Product.first } + + it "destroys the requested product" do + expect do + delete api_v1_product_url(product), + headers: valid_headers[:first_dev], as: :json + end.to change(Product, :count).by(-1) + end + + it 'returns a 204 status code' do + delete api_v1_product_url(product), + headers: valid_headers[:first_dev], as: :json + + expect(response).to have_http_status(:no_content) + end + + it 'returns no content in the response body' do + delete api_v1_product_url(product), + headers: valid_headers[:first_dev], as: :json + + expect(response.body).to be_empty + end + end +end From 8bb5eb4ab9493a3bae2329dca7451e3b17fae23e Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:10:58 +0000 Subject: [PATCH 50/52] feat: Add controller actions for Product endpoints --- app/controllers/api/v1/products_controller.rb | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 app/controllers/api/v1/products_controller.rb diff --git a/app/controllers/api/v1/products_controller.rb b/app/controllers/api/v1/products_controller.rb new file mode 100644 index 0000000..7c37238 --- /dev/null +++ b/app/controllers/api/v1/products_controller.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Api + module V1 + # rubocop:disable Metrics/ClassLength + + # Handles CRUD operations for products + class ProductsController < ApplicationController + before_action :set_product, only: %i[show update destroy] + after_action :invalidate_cache, only: %i[update destroy] + + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + + # GET /api/v1/products + # Retrieves a paginated list of products for a developer. + # Caches the response for 2 hours. + def index + page = params[:page] || 1 + + # Initialize the products query with developer_id and app_id + products = Product.where(developer_id:, app_id:) + .page(page) + .per(page_size) + + # Apply filtering based on query parameters + products = products.by_name(params[:name]) + .by_category(params[:category_id]) + .by_price_range(params[:min_price], + params[:max_price]) + + # Set the cache key based on the filtered products + cache_key = products_cache_key(developer_id:, page:, page_size:, + name: params[:name], + category_id: params[:category_id], + min_price: params[:min_price], + max_price: params[:max_price]) + + # Fetch or cache the response + response = Rails.cache.fetch(cache_key, expires_in: 2.hours) do + products_array = products.to_a + json_response( + products_array, + message: 'Products retrieved successfully', + serializer: + ) + end + + render json: response + end + + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize + + # GET /api/v1/products/:id + # Retrieves a specific product by ID. + def show + render json: json_response( + @product, + serializer:, + message: 'Product retrieved successfully' + ) + end + + # POST /api/v1/products + # Creates a new product. + # Validates the presence of the category ID. + def create + return render_category_error if invalid_category_id? + + product = Product.create!(product_params) + + render json: json_response( + product, + message: 'Product created successfully', + serializer: + ), status: :created + end + + # PATCH/PUT /api/v1/products/:id + # Updates an existing product. + def update + @product.update!(product_params) + + render json: json_response( + @product, + serializer:, + message: 'Product updated successfully' + ) + end + + # DELETE /api/v1/products/:id + # Deletes a specific product. + def destroy + @product.destroy! + head :no_content + end + + private + + # Sets the product instance variable based on the provided ID and + # developer token. If the product is found in the cache, it is assigned + # to @product. If not found or the cached product is stale, it is + # fetched from the database. + def set_product + @product = Rails.cache.fetch(cache_key, expires_in: 2.hours) do + Product.find_by!(id: params[:id], developer_id:, app_id:) + end + end + + # Strong parameters for product creation and updates. + def product_params + params.require(:product) + .permit(:name, :description, :price, :category_id, :available, + :currency, :stock_quantity) + .merge(developer_id:, user_id:, app_id:) + end + + # Returns the serializer class for the product. + def serializer + ProductSerializer + end + + # Checks if the provided category ID is valid. + def invalid_category_id? + category_id = product_params[:category_id] + category_id.present? && !validate_category_id(category_id) + end + + # Renders an error response for invalid category ID. + def render_category_error + render_error( + error: 'Category not found', + status: :bad_request, + details: { message: 'Verify you have the category you specified' } + ) + end + + # Validates the existence of a category by ID. + def validate_category_id(category_id) + Rails.cache.fetch("category_#{category_id}_#{developer_token}") do + Category.exists?(id: category_id, developer_id:) + end + end + + # rubocop:disable Metrics/ParameterLists + + # Generates a cache key for product pagination based on developer ID + # and page info. + def products_cache_key(developer_id:, page:, page_size:, name: nil, + category_id: nil, min_price: nil, max_price: nil) + + key_parts = %W[developer_#{developer_id} page_#{page} + size-#{page_size}] + + key_parts << "name-#{name}" if name.present? + key_parts << "category_id-#{category_id}" if category_id.present? + key_parts << "min_price-#{min_price}" if min_price.present? + key_parts << "max_price-#{max_price}" if max_price.present? + + key_parts.join('_') + end + + # rubocop:enable Metrics/ParameterLists + + # Generates the cache key for the specific product. + def cache_key + "product_#{params[:id]}_#{developer_id}" + end + + # Invalidates the cache for the product and its updated_at timestamp. + def invalidate_cache + Rails.cache.delete(cache_key) + Rails.cache.delete(updated_at_cache_key) + end + + # Generates the cache key for the product's updated_at timestamp. + def updated_at_cache_key + "#{cache_key}_updated_at" + end + end + end + + # rubocop:enable Metrics/ClassLength +end From 0d53119156e0f8371bb9a5caf948f3b62ea7e204 Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:11:18 +0000 Subject: [PATCH 51/52] feat: Add serializer for products --- app/serializers/product_serializer.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/serializers/product_serializer.rb diff --git a/app/serializers/product_serializer.rb b/app/serializers/product_serializer.rb new file mode 100644 index 0000000..7706694 --- /dev/null +++ b/app/serializers/product_serializer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Serializer for Products +class ProductSerializer + include JSONAPI::Serializer + + attributes( + *Product.attribute_names.map(&:to_sym).reject do |attr| + %i[id category_id].include?(attr) + end + ) + + belongs_to :category + + attribute :links do |product| + { + self: Rails.application.routes.url_helpers.api_v1_product_url(product) + } + end +end From 02daaf8aca2a2c550cb504feb8adb64d00e6565d Mon Sep 17 00:00:00 2001 From: theLazyProgrammer <48143641+nanafox@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:12:43 +0000 Subject: [PATCH 52/52] style: Rubocop style fixes --- app/controllers/api/v1/products_controller.rb | 14 +- spec/requests/api/v1/products_spec.rb | 136 +++++++++--------- spec/support/authentication_helper.rb | 2 +- 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/app/controllers/api/v1/products_controller.rb b/app/controllers/api/v1/products_controller.rb index 7c37238..ebb7ab2 100644 --- a/app/controllers/api/v1/products_controller.rb +++ b/app/controllers/api/v1/products_controller.rb @@ -27,14 +27,14 @@ def index products = products.by_name(params[:name]) .by_category(params[:category_id]) .by_price_range(params[:min_price], - params[:max_price]) + params[:max_price]) # Set the cache key based on the filtered products cache_key = products_cache_key(developer_id:, page:, page_size:, - name: params[:name], - category_id: params[:category_id], - min_price: params[:min_price], - max_price: params[:max_price]) + name: params[:name], + category_id: params[:category_id], + min_price: params[:min_price], + max_price: params[:max_price]) # Fetch or cache the response response = Rails.cache.fetch(cache_key, expires_in: 2.hours) do @@ -112,7 +112,7 @@ def set_product def product_params params.require(:product) .permit(:name, :description, :price, :category_id, :available, - :currency, :stock_quantity) + :currency, :stock_quantity) .merge(developer_id:, user_id:, app_id:) end @@ -148,7 +148,7 @@ def validate_category_id(category_id) # Generates a cache key for product pagination based on developer ID # and page info. def products_cache_key(developer_id:, page:, page_size:, name: nil, - category_id: nil, min_price: nil, max_price: nil) + category_id: nil, min_price: nil, max_price: nil) key_parts = %W[developer_#{developer_id} page_#{page} size-#{page_size}] diff --git a/spec/requests/api/v1/products_spec.rb b/spec/requests/api/v1/products_spec.rb index 7dc9728..ff9b340 100644 --- a/spec/requests/api/v1/products_spec.rb +++ b/spec/requests/api/v1/products_spec.rb @@ -185,7 +185,7 @@ it 'returns a 401' do get api_v1_products_url, - headers: { 'X-Developer-Token': UUID7.generate } + headers: { 'X-Developer-Token': UUID7.generate } expect(response).to have_http_status(:unauthorized) expect(response_body.dig(:meta, :status_code)).to eq(401) @@ -193,7 +193,7 @@ it 'returns an error message mentioning X-User-Id is missing' do get api_v1_products_url, - headers: { 'X-Developer-Token': UUID7.generate } + headers: { 'X-Developer-Token': UUID7.generate } expect(response_body.dig(:details, :error)).to eq('Invalid user ID') expect(response_body.dig(:details, :message)).to eq( @@ -238,19 +238,19 @@ context 'with name filter' do let!(:filtered_product) do FactoryBot.create(:product, - name: 'Filtered Product', - developer_id: developers.dig(:first, :id), - user_id: users.dig(:one, :id), - app_id: users.dig(:one, :app_id), - price: 100, - stock_quantity: 10, - description: 'A filtered case' * 10, - category_id: nil) + name: 'Filtered Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 100, + stock_quantity: 10, + description: 'A filtered case' * 10, + category_id: nil) end it 'returns filtered products by name' do get api_v1_products_url, headers: valid_headers[:first_dev], - params: { name: 'Filtered Product' } + params: { name: 'Filtered Product' } expect(response).to have_http_status(:success) expect(response_body[:data].size).to eq(1) @@ -262,8 +262,8 @@ context 'with category filter' do it 'returns filtered products by category' do get api_v1_products_url, - headers: valid_headers[:first_dev], - params: { category_id: first_dev_category_kitchen.id } + headers: valid_headers[:first_dev], + params: { category_id: first_dev_category_kitchen.id } expect(response).to have_http_status(:success) expect(response_body[:data].size).to eq(3) @@ -279,31 +279,31 @@ context 'with price range filter' do let!(:cheap_price) do FactoryBot.create(:product, - name: 'Cheap Product', - developer_id: developers.dig(:first, :id), - user_id: users.dig(:one, :id), - app_id: users.dig(:one, :app_id), - price: 5, - stock_quantity: 10, - description: 'A cheap product ' * 4, - category_id: first_dev_category_kitchen.id) + name: 'Cheap Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 5, + stock_quantity: 10, + description: 'A cheap product ' * 4, + category_id: first_dev_category_kitchen.id) end let!(:expensive_price) do FactoryBot.create(:product, - name: 'Expensive Product', - developer_id: developers.dig(:first, :id), - user_id: users.dig(:one, :id), - app_id: users.dig(:one, :app_id), - price: 15, - stock_quantity: 10, - description: 'An expensive product ' * 4, - category_id: first_dev_category_kitchen.id) + name: 'Expensive Product', + developer_id: developers.dig(:first, :id), + user_id: users.dig(:one, :id), + app_id: users.dig(:one, :app_id), + price: 15, + stock_quantity: 10, + description: 'An expensive product ' * 4, + category_id: first_dev_category_kitchen.id) end it 'returns products within the specified price range' do get api_v1_products_url, headers: valid_headers[:first_dev], - params: { min_price: 5, max_price: 10 } + params: { min_price: 5, max_price: 10 } expect(response).to have_http_status(:success) expect(response_body[:data].size).to eq(1) @@ -341,7 +341,7 @@ it 'caches the product response with filters' do get api_v1_products_url, headers: valid_headers[:first_dev], - params: { name: 'Microwave' } + params: { name: 'Microwave' } expect(response).to have_http_status(:ok) expect(Rails.cache).to have_received(:fetch) @@ -361,7 +361,7 @@ let!(:product) do Product.find_by(developer_id: developers.dig(:first, :id), - app_id: users.dig(:one, :app_id)) + app_id: users.dig(:one, :app_id)) end it "renders a successful response" do @@ -403,14 +403,14 @@ context 'errors' do it 'returns a 404 status code for non-existent products' do get api_v1_product_url(UUID7.generate), - headers: valid_headers[:first_dev] + headers: valid_headers[:first_dev] expect(response).to have_http_status(404) end it 'returns the expected response body format for errors' do get api_v1_product_url(UUID7.generate), - headers: valid_headers[:first_dev] + headers: valid_headers[:first_dev] expect(response_body.keys).to contain_exactly( :error, :meta, :details @@ -429,7 +429,7 @@ it 'returns a JSON response' do get api_v1_product_url(UUID7.generate), - headers: valid_headers[:first_dev] + headers: valid_headers[:first_dev] expect(response.content_type).to include('application/json') end end @@ -448,23 +448,23 @@ it "creates a new Product" do expect do post api_v1_products_url, - params: { product: valid_attributes[:product_one] }, - headers: valid_headers[:first_dev], as: :json + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json end.to change(Product, :count).by(1) end it 'returns a 201 status code' do post api_v1_products_url, - params: { product: valid_attributes[:product_one] }, - headers: valid_headers[:first_dev], as: :json + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:created) end it "renders a JSON response with the new product" do post api_v1_products_url, - params: { product: valid_attributes[:product_one] }, - headers: valid_headers[:first_dev], as: :json + params: { product: valid_attributes[:product_one] }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:created) expect(response.content_type).to include("application/json") @@ -477,8 +477,8 @@ ) post api_v1_products_url, - params: { product: }, - headers: valid_headers[:first_dev], as: :json + params: { product: }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(400) end @@ -489,8 +489,8 @@ ) post api_v1_products_url, - params: { product: }, - headers: valid_headers[:first_dev], as: :json + params: { product: }, + headers: valid_headers[:first_dev], as: :json expect(response.content_type).to include("application/json") end @@ -501,8 +501,8 @@ ) post api_v1_products_url, - params: { product: }, - headers: valid_headers[:first_dev], as: :json + params: { product: }, + headers: valid_headers[:first_dev], as: :json expect(response_body.dig(:details, :message)).to \ include('Verify you have the category you specified') @@ -516,33 +516,33 @@ it "does not create a new Product" do expect do post api_v1_products_url, - headers: valid_headers[:first_dev], - params: { product: invalid_attributes }, as: :json + headers: valid_headers[:first_dev], + params: { product: invalid_attributes }, as: :json end.not_to change(Product, :count) end it "renders a JSON response with errors for the new product" do post api_v1_products_url, - params: { product: invalid_attributes }, - headers: valid_headers[:first_dev], as: :json + params: { product: invalid_attributes }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:unprocessable_content) expect(response.content_type).to include("application/json") end it 'returns a 422 when the product price is not provided' do post api_v1_products_url, - params: { product: invalid_attributes[:product_with_no_price] }, - headers: valid_headers[:first_dev], as: :json + params: { product: invalid_attributes[:product_with_no_price] }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(422) end it 'returns a 422 when the price is a not a numeric value' do post api_v1_products_url, - params: { - product: invalid_attributes[:product_with_non_numeric_price] - }, - headers: valid_headers[:first_dev], as: :json + params: { + product: invalid_attributes[:product_with_non_numeric_price] + }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:unprocessable_content) end @@ -552,12 +552,12 @@ it "returns a 422 status code for each invalid attribute set" do invalid_attributes.each do |key, value| post api_v1_products_url, - params: { product: value }, - headers: valid_headers[:first_dev], as: :json + params: { product: value }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(422), - "Expected 422 for #{key} but got" \ - " #{response.status}" + "Expected 422 for #{key} but got" \ + " #{response.status}" end end end @@ -579,16 +579,16 @@ it "updates the requested product" do patch api_v1_product_url(product), - params: { product: new_attributes }, - headers: valid_headers[:first_dev], as: :json + params: { product: new_attributes }, + headers: valid_headers[:first_dev], as: :json product.reload expect(product.name).to eq(new_attributes[:name]) end it "renders a JSON response with the product" do patch api_v1_product_url(product), - params: { product: new_attributes }, - headers: valid_headers[:first_dev], as: :json + params: { product: new_attributes }, + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:ok) expect(response.content_type).to include("application/json") end @@ -610,20 +610,20 @@ it "destroys the requested product" do expect do delete api_v1_product_url(product), - headers: valid_headers[:first_dev], as: :json + headers: valid_headers[:first_dev], as: :json end.to change(Product, :count).by(-1) end it 'returns a 204 status code' do delete api_v1_product_url(product), - headers: valid_headers[:first_dev], as: :json + headers: valid_headers[:first_dev], as: :json expect(response).to have_http_status(:no_content) end it 'returns no content in the response body' do delete api_v1_product_url(product), - headers: valid_headers[:first_dev], as: :json + headers: valid_headers[:first_dev], as: :json expect(response.body).to be_empty end diff --git a/spec/support/authentication_helper.rb b/spec/support/authentication_helper.rb index 2fef6b0..a31fe07 100644 --- a/spec/support/authentication_helper.rb +++ b/spec/support/authentication_helper.rb @@ -2,7 +2,7 @@ module AuthenticationHelper def mock_authentication(controller_class:, developer_id: nil, user_id: nil, - app_id: nil) + app_id: nil) allow_any_instance_of(UserServiceClient).to \ receive(:fetch_developer_id).and_return(developer_id)