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/ diff --git a/Gemfile b/Gemfile index e9aae73..99d8767 100644 --- a/Gemfile +++ b/Gemfile @@ -8,10 +8,8 @@ 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" +gem 'redis', '>= 4.0.1' # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" @@ -46,9 +44,21 @@ group :development, :test do gem 'factory_bot_rails' gem 'dotenv-rails' + + # code coverage + gem 'simplecov', require: false end gem 'uuid7', '~> 0.2.0' # for pagination gem 'kaminari' + +# for JSON response serialization +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 eb84ada..7ae65fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,11 +82,17 @@ 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) + database_cleaner-core (2.0.1) date (3.3.4) debug (1.9.2) 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) @@ -100,16 +106,19 @@ GEM railties (>= 5.0.0) globalid (1.2.1) activesupport (>= 6.1) - i18n (1.14.6) + 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) irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.13.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) @@ -136,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 @@ -209,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) @@ -259,6 +274,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) @@ -285,16 +306,20 @@ PLATFORMS DEPENDENCIES bootsnap brakeman + database_cleaner-active_record debug dotenv-rails factory_bot_rails - jbuilder + 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 tzinfo-data uuid7 (~> 0.2.0) diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb new file mode 100644 index 0000000..9b68e76 --- /dev/null +++ b/app/controllers/api/v1/categories_controller.rb @@ -0,0 +1,128 @@ +# 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 + # Before actions to set up authorization and category object + 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 + categories = Category.all + + categories = perform_filtering(categories) + + paginated_categories = categories.page(params[:page]).per(page_size) + + render json: json_response( + paginated_categories, serializer:, + message: 'Categories retrieved successfully' + ) + end + + # 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 + 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) + + @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/:id + # PATCH/PUT /api/v1/categories/:id.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/:id + # DELETE /api/v1/categories/:id.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 + + # Sets the category instance variable based on the ID and developer + # token + def set_api_v1_category + 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. + # 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:) + end + + # Returns the serializer class for the category + def serializer + CategorySerializer + end + + def perform_filtering(categories) + # Filter categories by developer token + categories = categories.where(developer_id:) + + # 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 diff --git a/app/controllers/api/v1/products_controller.rb b/app/controllers/api/v1/products_controller.rb new file mode 100644 index 0000000..ebb7ab2 --- /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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 13c271f..1697c20 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,102 @@ # frozen_string_literal: true +# 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 + rescue_from ActionController::ParameterMissing, with: :bad_request + + # 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:, + meta: final_meta, + details: + }, status: numeric_status_code + end + + # rubocop:enable Metrics/MethodLength + + def invalid_route + 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: "#{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) + match_data = error.message.match(/Key \((.+)\)=\((.+)\) already exists/) + 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', + status: :internal_server_error + ) + end + + # Handles validation errors + def validation_error(error) + render_error( + error: 'Validation Failed', + details: error.record.errors.to_hash(full_messages: true), + status: :unprocessable_content + ) + end + + def bad_request(error) + render_error( + error: 'Bad Request', + details: error.message, + status: :bad_request + ) + end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..27c4861 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# app/controllers/concerns/authentication.rb +module Authentication + extend ActiveSupport::Concern + + included do + before_action :verify_authentication_credentials! + end + + 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 + + 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? + + return unless verify_user_id! + + verify_app_id! + end + + def skip_user_id_verification? + is_a?(Api::V1::CategoriesController) + end + + # rubocop:disable Metrics/MethodLength + def verify_developer_token! + 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 + + def verify_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 app ID', + message: 'Please provide a valid app ID. E.g., X-App-Id: ' + }, + status: :unauthorized + ) + end + + # rubocop:enable Metrics/MethodLength + + def user_service_client + @user_service_client ||= UserServiceClient.new + end +end diff --git a/app/controllers/concerns/json_response.rb b/app/controllers/concerns/json_response.rb new file mode 100644 index 0000000..2a19f19 --- /dev/null +++ b/app/controllers/concerns/json_response.rb @@ -0,0 +1,87 @@ +# 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: {} + ) + + metadata = { + status_code:, + success: status_code < 400 + } + + metadata.merge(extra_meta) + + if paginated?(resource) + render_paginated(resource, message:, extra_meta: metadata, serializer:) + else + render_single(resource, message:, extra_meta: metadata, 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 + + def status_code + return 201 if response.request.method == 'POST' + + 200 + end +end diff --git a/app/controllers/concerns/pagination_helper.rb b/app/controllers/concerns/pagination_helper.rb new file mode 100644 index 0000000..ebeeee7 --- /dev/null +++ b/app/controllers/concerns/pagination_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Helper for API response pagination +module PaginationHelper + 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, 1].max + page_size = resource.limit_value + + { + meta: { + total_count: resource.total_count, + current_count: resource.count, message: + }, + links: create_pagination_links(page, total_pages, page_size) + } + end + + 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 diff --git a/app/models/product.rb b/app/models/product.rb index c9b4a39..20f972e 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -4,10 +4,23 @@ class Product < ApplicationRecord include WordCountValidatable - belongs_to :category + 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, :category_id, :price, :user_id, - :stock_quantity, :description, presence: true + validates :developer_id, :name, :price, :user_id, :app_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/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb new file mode 100644 index 0000000..3b4b5e8 --- /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 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 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 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 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 diff --git a/config/routes.rb b/config/routes.rb index d55c305..77079e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true 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 + # Define your application routes per the DSL in # https://guides.rubyonrails.org/routing.html @@ -10,6 +17,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 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/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 96beb07..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_907_131_847) 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" @@ -35,11 +35,14 @@ 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.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 diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 06c55d0..8af6e2a 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -134,4 +134,155 @@ 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.', + app_id: UUID7.generate + ) + 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..f2149f0 100644 --- a/spec/models/product_spec.rb +++ b/spec/models/product_spec.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true require 'rails_helper' + 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', @@ -12,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 @@ -31,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 @@ -89,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 @@ -100,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) @@ -114,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) @@ -124,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) @@ -135,4 +142,125 @@ 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, + app_id:) + 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, + app_id: + ) + 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 diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb new file mode 100644 index 0000000..e491d64 --- /dev/null +++ b/spec/requests/api/v1/categories_spec.rb @@ -0,0 +1,639 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/shared_contexts' + +RSpec.describe "/api/v1/categories", type: :request do + include_context 'common data' + + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(nil) + + allow_any_instance_of(Api::V1::CategoriesController).to \ + receive(:developer_id).and_return(nil) + end + + let(:valid_attributes) do + { + name: 'Home Appliance', + description: 'Everything home appliance goes here' + } + end + + let(:invalid_attributes) do + { name: '', description: '' } # no category name and description + end + + context 'with valid developer token: using first dev' do + let(:expected_developer_id) { developers.dig(:first, :id) } + + before do + allow_any_instance_of(UserServiceClient).to \ + receive(:fetch_developer_id).and_return(expected_developer_id) + + 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 + 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: 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: valid_headers[:first_dev] + expect(response_body).to have_key(:data) + end + + it 'contains RESTful meta information' do + get api_v1_categories_url, headers: valid_headers[:first_dev] + 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 'Pagination' do + it 'contains pagination links' do + 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 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) + + 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: valid_headers[:first_dev] + + 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: valid_headers[:first_dev] + + 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 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) + + 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: valid_headers[:first_dev] + + 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: 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( + developers.dig(:first, :id) + ) + 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: 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: 'lotions' }, + headers: valid_headers[:first_dev] + + 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: 'blah' }, + headers: valid_headers[:first_dev] + + 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: valid_headers[:first_dev] + + 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: 'kitchen' }, + headers: valid_headers[:first_dev] + + 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('Kitchen Appliances') + 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_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_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_kitchen), + headers: valid_headers[:first_dev] + + 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_kitchen.name + ) + expect(response_data.dig(:attributes, :description)).to eq( + first_dev_category_kitchen.description + ) + expect(response_data.dig(:attributes, :developer_id)).to eq( + 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: 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 + 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_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_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_computers), + headers: valid_headers[:second_dev] + + 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_computers.name + ) + expect(response_data.dig(:attributes, :description)).to eq( + second_dev_category_computers.description + ) + expect(response_data.dig(:attributes, :developer_id)).to eq( + 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: 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 + 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 + 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: valid_headers[:first_dev], 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_kitchen.name, + description: first_dev_category_kitchen.description + } + }, + headers: valid_headers[:first_dev], 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: valid_headers[:first_dev], 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: valid_headers[:first_dev], + 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: valid_headers[:first_dev], 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_kitchen), + params: { category: new_attributes }, + headers: valid_headers[:first_dev], as: :json + + first_dev_category_kitchen.reload + expect(response).to have_http_status(:ok) + + expect(first_dev_category_kitchen.description).to eq( + new_attributes[:description] + ) + 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_kitchen), + params: { category: 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 + + it "rejects updates from developers who do not own the category" do + 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: 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. + 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_kitchen), + params: { category: 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 + 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_kitchen), + headers: valid_headers[:first_dev], 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: valid_headers[:first_dev], 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_kitchen), + headers: valid_headers[:first_dev], + 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 + 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. + expect(response).to have_http_status(:not_found) + end + end + end + end + + context 'with an invalid or missing developer token' 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 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 + + 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 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), + params: { category: valid_attributes } + + expect(response).to have_http_status(:unauthorized) + end + + 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 diff --git a/spec/requests/api/v1/products_spec.rb b/spec/requests/api/v1/products_spec.rb new file mode 100644 index 0000000..ff9b340 --- /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 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 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..e8410c5 --- /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 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 409c64b..503aaba 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,26 @@ # frozen_string_literal: true +require 'support/requests_helper' +require 'support/authentication_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`. # The generated `.rspec` file contains `--require spec_helper` which will cause @@ -91,4 +112,15 @@ # # 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 + config.include AuthenticationHelper, type: :request end diff --git a/spec/support/authentication_helper.rb b/spec/support/authentication_helper.rb new file mode 100644 index 0000000..a31fe07 --- /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 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/requests_helper.rb b/spec/support/requests_helper.rb new file mode 100644 index 0000000..39e7194 --- /dev/null +++ b/spec/support/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 diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb new file mode 100644 index 0000000..82c0cc8 --- /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(:first, :app_id) + }, + two: { + id: '0191efff-0a87-7850-9096-cbbf17145710', + app_id: developers.dig(:first, :app_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