diff --git a/Gemfile.lock b/Gemfile.lock index a5fd3615d..ba2a784a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,7 +152,7 @@ GEM bindex (0.8.1) bootsnap (1.21.1) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.1) racc builder (3.3.0) bundler-audit (0.9.3) @@ -214,6 +214,7 @@ GEM faraday-net_http (3.4.2) net-http (~> 0.5) ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86_64-darwin) ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-musl) file_validators (3.0.0) @@ -304,6 +305,8 @@ GEM ffi (~> 1.0) libddwaf (1.30.0.0.0-arm64-darwin) ffi (~> 1.0) + libddwaf (1.30.0.0.0-x86_64-darwin) + ffi (~> 1.0) libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) lint_roller (1.1.0) @@ -359,6 +362,8 @@ GEM nio4r (2.7.5) nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.0-x86_64-darwin) + racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) @@ -391,6 +396,7 @@ GEM ast (~> 2.4.1) racc pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) pp (0.6.3) @@ -644,6 +650,7 @@ GEM PLATFORMS arm64-darwin + x86_64-darwin x86_64-linux-gnu x86_64-linux-musl @@ -737,7 +744,7 @@ CHECKSUMS bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 - brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce + brakeman (8.0.1) sha256=c68ce0ac35a6295027c4eab8b4ac597d2a0bfc82f0d62dcd334bbf944d352f70 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d @@ -771,6 +778,7 @@ CHECKSUMS faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f + ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5 ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 file_validators (3.0.0) sha256=43700f0c1fe9b235bf7f4701375e1d2f9391352ab26723f123b934724db8067b @@ -805,6 +813,7 @@ CHECKSUMS libdatadog (25.0.0.1.0-x86_64-linux) sha256=260d78ab20bf5b5db310aade93900a02adf18434a5c0b28fec4d05db54f7c206 libddwaf (1.30.0.0.0) sha256=d5c350555ec5bdfb99534b37ad578163c83642ca03cecb3bae30fd29dc47d4fc libddwaf (1.30.0.0.0-arm64-darwin) sha256=b4997ab7e8a4c41fa3993b00d89c209dfbed4d5ac5c03fc1dd70464185ffe193 + libddwaf (1.30.0.0.0-x86_64-darwin) sha256=749989cf5b3ad3689969019706875260ffff5a91ebafcbf44766a3d635a0ac90 libddwaf (1.30.0.0.0-x86_64-linux) sha256=c8d7e1e097ed12bcdf5d4bcb716edcb98e72733c80dd884e0ee83a7cc43d6ae0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 @@ -834,6 +843,7 @@ CHECKSUMS netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 + nokogiri (1.19.0-x86_64-darwin) sha256=1dad56220b603a8edb9750cd95798bffa2b8dd9dd9aa47f664009ee5b43e3067 nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738 @@ -846,6 +856,7 @@ CHECKSUMS parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb new file mode 100644 index 000000000..05fb04209 --- /dev/null +++ b/app/controllers/content_status_controller.rb @@ -0,0 +1,86 @@ +class ContentStatusController < ApplicationController + def index + metrics = { + tools: { + default: Language.joins(resource_default_orders: {resource: :resource_type}).where( + resource_types: {name: "tract"} + ).distinct("languages.id").count, + featured: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "tract"}, + resource_scores: {featured: true} + ).distinct("languages.id").count, + ranked: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "tract"} + ).where.not(resource_scores: {score: nil}).distinct("languages.id").count, + total: Language.joins(resource_scores: {resource: :resource_type}).where(resource_types: {name: "tract"}).distinct("languages.id").count + }, + lessons: { + default: Language.joins(resource_default_orders: {resource: :resource_type}).where( + resource_types: {name: "lesson"} + ).distinct("languages.id").count, + featured: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "lesson"}, + resource_scores: {featured: true} + ).distinct("languages.id").count, + ranked: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "lesson"} + ).where.not(resource_scores: {score: nil}).distinct("languages.id").count, + total: Language.joins(resource_scores: {resource: :resource_type}).where(resource_types: {name: "lesson"}).distinct("languages.id").count + }, + countries: retrieve_countries_data + } + + render json: metrics, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + private + + def uniq_countries + ResourceScore.select(:country).distinct.pluck(:country) + end + + def retrieve_lessons_data(country, language) + { + featured: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: {name: "lesson"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, + ranked: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: {name: "lesson"}, resource_scores: {country: country} + ).where(resource_scores: {language: language}).where.not(resource_scores: {score: nil}).count + } + end + + def retrieve_tools_data(country, language) + { + featured: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: {name: "tract"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, + ranked: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: {name: "tract"}, resource_scores: {country: country} + ).where(resource_scores: {language: language}).where.not(resource_scores: {score: nil}).count + } + end + + def retrieve_language_data(country, language) + { + language_code: language.code.downcase, + language_name: language.name, + lessons: retrieve_lessons_data(country, language), + tools: retrieve_tools_data(country, language), + last_updated: ResourceScore.where(country: country, language: language).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" + } + end + + def retrieve_countries_data + uniq_countries.map do |country| + { + country_code: country, + languages: Language.joins(:resource_scores).where(resource_scores: {country: country}).distinct.map do |language| + retrieve_language_data(country, language) + end + } + end + end +end diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb new file mode 100644 index 000000000..8e2be0759 --- /dev/null +++ b/app/controllers/resource_default_orders_controller.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +class ResourceDefaultOrdersController < ApplicationController + before_action :authorize!, only: %i[create destroy update mass_update] + + def index + lang = params.dig(:filter, :lang) || params[:lang] + resource_type = params.dig(:filter, :resource_type) || params[:resource_type] + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + raise "Language not found for code: #{lang}" unless language.present? + end + + default_order_resources = all_default_order_resources(lang: lang, resource_type: resource_type) + + render json: default_order_resources, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + def create + sanitized_params = create_params + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order = ResourceDefaultOrder.new(sanitized_params) + @resource_default_order.language = language if language.present? + @resource_default_order.save! + render json: @resource_default_order, status: :created + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def destroy + @resource_default_order = ResourceDefaultOrder.find(params[:id]) + @resource_default_order.destroy! + render json: {}, status: :ok + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content + end + + def update + @resource_default_order = ResourceDefaultOrder.find(params[:id]) + sanitized_params = create_params + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order.language = language if language.present? + @resource_default_order.update!(sanitized_params) + render json: @resource_default_order, status: :ok + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def mass_update + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase + incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] + + raise "Language and Resource Type should be provided" unless lang_code.present? && resource_type_name.present? + + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? + + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" + end + + unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) + raise "resource_ids is expected to be an array of integers" + end + + raise "resource_ids is expected to include a maximum of 9 ids" if incoming_resource_ids.length > 9 + + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise "resource_ids cannot contain duplicate ids" + end + + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(", ")})" + end + + current_default_orders = ResourceDefaultOrder + .joins(:resource) + .where(language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(position: :asc) + .lock + .to_a + + ResourceDefaultOrder.transaction do + current_default_orders.each do |ro| + ro.update_column(:position, nil) + end + + orders_by_resource_id = current_default_orders.index_by(&:resource_id) + incoming_resource_ids.each_with_index do |resource_id, index| + relevant_order = orders_by_resource_id[resource_id] + + if relevant_order + relevant_order.update!(position: index + 1) + else + ResourceDefaultOrder.create!( + resource_id: resource_id, + language_id: language.id, + position: index + 1 + ) + end + end + current_default_orders.each do |ro| + ro.destroy! unless incoming_resource_ids.include?(ro.resource_id) + end + end + resulting_default_orders = ResourceDefaultOrder + .joins(:resource) + .where(language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(position: :asc) + + render json: resulting_default_orders, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + private + + def all_default_order_resources(lang:, resource_type: nil) + scope = Resource.joins(:resource_default_orders) + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + end + + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + end + + def create_params + params.require(:data).require(:attributes).permit( + :resource_id, :lang, :position + ) + end +end diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb new file mode 100644 index 000000000..4f53d9f43 --- /dev/null +++ b/app/controllers/resource_scores_controller.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +class ResourceScoresController < ApplicationController + before_action :authorize!, only: %i[create destroy update mass_update mass_update_ranked] + + def index + lang_code = params.dig(:filter, :lang) || params[:lang] + resource_scores = all_resource_scores( + lang_code: lang_code, + country: params.dig(:filter, :country) || params[:country], + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: resource_scores, include: params[:include], status: :ok + end + + def create + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score = ResourceScore.new(sanitized_params) + @resource_score.language = language if language.present? + @resource_score.save! + render json: @resource_score, status: :created + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def destroy + @resource_score = ResourceScore.find(params[:id]) + @resource_score.destroy! + render json: {}, status: :ok + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content + end + + def update + @resource_score = ResourceScore.find(params[:id]) + sanitized_params = create_params + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score.language = language if language.present? + @resource_score.update!(sanitized_params) + + render json: @resource_score, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def mass_update + country = params.dig(:data, :attributes, :country)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase + incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] + + unless country.present? && lang_code.present? && resource_type_name.present? + raise "Country, Language, and Resource Type should be provided" + end + + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? + + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" + end + + unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) + raise "resource_ids is expected to be an array of integers" + end + + raise "resource_ids is expected to include a maximum of 9 ids" if incoming_resource_ids.length > 9 + + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise "resource_ids cannot contain duplicate ids" + end + + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise %(Resources not found or do not match the provided resource type. + Invalid IDs: #{invalid_resource_ids.join(", ")}) + end + + current_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(:featured_order) + .lock + .to_a + + ResourceScore.transaction do + current_scores.each do |rs| + rs.update!(featured: false, featured_order: nil) + end + + scores_by_resource_id = current_scores.index_by(&:resource_id) + incoming_resource_ids.each_with_index do |resource_id, index| + relevant_score = scores_by_resource_id[resource_id] + if relevant_score + relevant_score.update!(featured: true, featured_order: index + 1) + else + ResourceScore.create!( + resource_id: resource_id, + country: country, + language_id: language.id, + featured_order: index + 1, + featured: true + ) + end + end + current_scores.each do |rs| + soft_delete_featured_score(rs) unless incoming_resource_ids.include?(rs.resource_id) + end + end + + resulting_resource_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id, featured: true) + .where(resources: {resource_type_id: resource_type.id}) + .order(:featured_order) + + render json: resulting_resource_scores, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + def mass_update_ranked + country = params.dig(:data, :attributes, :country)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase + incoming_resource_array = params.dig(:data, :attributes, :ranked_resources) || [] + symbolized_incoming_resource_array = incoming_resource_array.map { |r| r.to_unsafe_h.deep_symbolize_keys } + + unless country.present? && lang_code.present? && resource_type_name.present? + raise "Country, Language, and Resource Type should be provided" + end + + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? + + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" + end + + incoming_resource_ids = symbolized_incoming_resource_array.map { |r| r[:resource_id] } + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise "resource_ids cannot contain duplicate ids" + end + + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise "Resources not found or do not match the provided resource type. + Invalid resource ids: #{invalid_resource_ids.join(", ")}" + end + + current_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(score: :desc) + .lock + .to_a + + ResourceScore.transaction do + scores_by_resource_id = current_scores.index_by(&:resource_id) + + symbolized_incoming_resource_array.each do |incoming_resource| + resource_id = incoming_resource[:resource_id] + score = incoming_resource[:score] + + relevant_score = scores_by_resource_id[resource_id] + + if relevant_score + relevant_score.update!(score: score) + else + ResourceScore.create!( + resource_id: resource_id, + country: country, + language_id: language.id, + score: score + ) + end + end + preserved_resource_ids = symbolized_incoming_resource_array + .filter_map { |r| r[:resource_id] if r[:score].present? } + .to_set + + current_scores.each do |rs| + soft_delete_ranked_score(rs) unless preserved_resource_ids.include?(rs.resource_id) + end + end + + resulting_resource_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .where.not(score: nil) + .order(score: :desc) + + render json: resulting_resource_scores, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + private + + def create_params + params.require(:data).require(:attributes).permit( + :resource_id, :lang, :country, :score, :featured_order, :featured + ) + end + + def all_resource_scores(lang_code:, country:, resource_type: nil) + scope = ResourceScore.all + + if lang_code.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + scope = scope.left_joins(:language).where(languages: {id: language.id}) if language.present? + end + + scope = scope.where("LOWER(country) = LOWER(?)", country) if country.present? + + if resource_type.present? + scope = scope.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + scope.order("featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC") + end + + def soft_delete_featured_score(resource_score) + return if resource_score.nil? + + if resource_score.score.present? + resource_score.update!(featured: false, featured_order: nil) + else + resource_score.destroy! + end + end + + def soft_delete_ranked_score(resource_score) + return if resource_score.nil? + + if resource_score.featured_order.present? + resource_score.update!(score: nil) + else + resource_score.destroy! + end + end +end diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb deleted file mode 100644 index 583f3ef86..000000000 --- a/app/controllers/resources/default_order_controller.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module Resources - class DefaultOrderController < ApplicationController - before_action :authorize!, only: %i[create destroy update] - - def index - default_order_resources = all_default_order_resources( - lang: params.dig(:filter, :lang) || params[:lang], - resource_type: params.dig(:filter, :resource_type) || params[:resource_type] - ) - - render json: default_order_resources, include: params[:include], status: :ok - end - - def create - @resource_default_order = ResourceDefaultOrder.new(create_params) - @resource_default_order.save! - render json: @resource_default_order, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def destroy - @resource_default_order = ResourceDefaultOrder.find(params[:id]) - @resource_default_order.destroy! - render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, status: :unprocessable_content - end - - def update - @resource_default_order = ResourceDefaultOrder.find(params[:id]) - @resource_default_order.update!(create_params) - render json: @resource_default_order, status: :ok - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - private - - def all_default_order_resources(lang:, resource_type: nil) - scope = Resource.joins(:resource_default_orders) - - # Filter by language - if lang.present? - scope = scope.where("resource_default_orders.lang = LOWER(:lang)", lang:) - end - - # Filter by resource type - if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) - end - - scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") - end - - def create_params - params.require(:data).require(:attributes).permit( - :resource_id, :lang, :position - ) - end - end -end diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb deleted file mode 100644 index 45bf66ed5..000000000 --- a/app/controllers/resources/featured_controller.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -module Resources - class FeaturedController < ApplicationController - before_action :authorize!, only: %i[create destroy update mass_update] - - def index - featured_resources = all_featured_resources( - lang: params.dig(:filter, :lang) || params[:lang], - country: params.dig(:filter, :country) || params[:country], - resource_type: params.dig(:filter, :resource_type) || params[:resource_type] - ) - - render json: featured_resources, include: params[:include], status: :ok - end - - def create - @resource_score = ResourceScore.new(create_params) - @resource_score.save! - render json: @resource_score, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def destroy - @resource_score = ResourceScore.find(params[:id]) - @resource_score.destroy! - render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, status: :unprocessable_content - end - - def update - @resource_score = ResourceScore.find(params[:id]) - @resource_score.update!(create_params) - render json: @resource_score, status: :ok - rescue ActiveRecord::RecordInvalid => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def mass_update - country = params.dig(:data, :attributes, :country)&.downcase - lang = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) - incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] - resulting_resource_scores = [] - - current_scores = ResourceScore.where( - country: country, lang: lang - ).order(featured_order: :asc) - - if resource_type.present? - current_scores = current_scores.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) - end - - raise "Country and/or Lang should be provided" unless country.present? && lang.present? - - current_scores = current_scores.to_a - - if incoming_resources.empty? - current_scores.each do |rs| - soft_delete_resource_score(rs) - end - current_scores.reject! { |rs| !rs.persisted? } - - return render json: current_scores, status: :ok - end - - ResourceScore.transaction do - ResourceScore::MAX_FEATURED_ORDER_POSITION.times do |index| - resource_id = incoming_resources[index] - current_featured_order = index + 1 - - if resource_id.nil? - # Remove any existing resource score at this position - resource_score_to_remove = current_scores.find { |rs| rs.featured_order == current_featured_order } - soft_delete_resource_score(resource_score_to_remove) - next - end - - incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - current_resource_score_at_position = current_scores.find { |rs| rs.featured_order == current_featured_order && rs.featured == true } - - if incoming_resource_score - if incoming_resource_score.featured_order != current_featured_order - # Incoming ResourceScore exists but at a different position - # Remove ResourceScore currently at this position, if any - if current_resource_score_at_position - soft_delete_resource_score(current_resource_score_at_position) - current_scores.reject! { |rs| rs.id == current_resource_score_at_position.id } - end - - # Move incoming ResourceScore to the new position - incoming_resource_score.update!(featured_order: current_featured_order, featured: true) - resulting_resource_scores << incoming_resource_score - else - # Incoming ResourceScore exists and is already at the correct position - incoming_resource_score.update!(featured: true) - resulting_resource_scores << incoming_resource_score - next - end - elsif current_resource_score_at_position - # There is a ResourceScore at this position, update it to the new resource_id - current_resource_score_at_position.update!(resource_id: resource_id, featured: true) - resulting_resource_scores << current_resource_score_at_position - else - # No ResourceScore at this position, create a new one - resulting_resource_scores << ResourceScore.create!( - resource_id: resource_id, - lang: lang, - country: country, - featured: true, - featured_order: current_featured_order - ) - end - end - end - render json: resulting_resource_scores, status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content - end - - private - - def create_params - params.require(:data).require(:attributes).permit( - :resource_id, :lang, :country, :score, :featured_order, :featured - ) - end - - def all_featured_resources(lang:, country:, resource_type: nil) - scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) - - scope = scope.where("resource_scores.lang = LOWER(:lang)", lang:) if lang.present? - scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? - - scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ - resource_scores.score DESC NULLS LAST, \ - resources.created_at DESC") - end - - def soft_delete_resource_score(resource_score) - return if resource_score.nil? - - if resource_score.score.present? - resource_score.update!(featured: false, featured_order: nil) - else - resource_score.destroy! - end - end - end -end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index dde281ad2..b7631dea9 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ResourcesController < ApplicationController - before_action :authorize!, only: [:create, :update, :publish_translation] + before_action :authorize!, only: %i[create update publish_translation] def index render json: cached_index_json, status: :ok @@ -18,9 +18,7 @@ def create def update resource = load_resource - if resource.update!(permitted_params) - resource.set_data_attributes!(data_attrs) - end + resource.set_data_attributes!(data_attrs) if resource.update!(permitted_params) render json: resource, status: :ok end @@ -29,11 +27,41 @@ def suggestions render json: ToolFilterService.new(params).call, include: params[:include], fields: field_params, status: :ok end + def featured + lang_code = params.dig(:filter, :lang) || params[:lang] + featured_resources = all_featured_resources( + lang_code: lang_code, + country: params.dig(:filter, :country) || params[:country], + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: featured_resources, include: params[:include], fields: field_params, status: :ok + end + + def default_order + lang = params.dig(:filter, :lang) || params[:lang] + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + raise "Language not found for code: #{lang}" unless language.present? + end + + default_order_resources = all_default_order_resources( + lang: lang, + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: default_order_resources, include: params[:include], fields: field_params, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + def publish_translation if valid_publish_params? render json: publish_translations, status: :ok else - render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, status: :unprocessable_content + render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, + status: :unprocessable_content end end @@ -55,11 +83,11 @@ def publish_translations def publish_translation_for_language(language_data) draft_translation = find_or_create_draft_translation(language_data["id"]) - if draft_translation - draft_translation.update(publishing_errors: nil) - PublishTranslationJob.perform_async(draft_translation.id) - draft_translation - end + return unless draft_translation + + draft_translation.update(publishing_errors: nil) + PublishTranslationJob.perform_async(draft_translation.id) + draft_translation end def find_or_create_draft_translation(language_id) @@ -91,9 +119,7 @@ def all_resources Resource.all end - if params.dig(:filter, :abbreviation) - resources = resources.where(abbreviation: params[:filter][:abbreviation]) - end + resources = resources.where(abbreviation: params[:filter][:abbreviation]) if params.dig(:filter, :abbreviation) if params.dig(:filter, :resource_type) resources = resources.joins(:resource_type).where(resource_types: {name: params[:filter][:resource_type].downcase}) @@ -102,11 +128,44 @@ def all_resources resources end + def all_featured_resources(lang_code:, country:, resource_type: nil) + scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) + + if lang_code.present? + language = Language.find_by(code: lang_code.downcase) + scope = scope.joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? + end + + scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + + scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ + resource_scores.score DESC NULLS LAST, \ + resources.created_at DESC") + end + + def all_default_order_resources(lang:, resource_type: nil) + scope = Resource.joins(:resource_default_orders) + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + end + + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + end + def load_resource Resource.find(params[:id]) end def permitted_params - permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, :metatool_id, :default_variant_id) + permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, + :metatool_id, :default_variant_id) end end diff --git a/app/models/language.rb b/app/models/language.rb index 5231a0c1c..a6f23b179 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -6,8 +6,10 @@ class Language < ActiveRecord::Base has_many :custom_tips, dependent: :restrict_with_error has_many :translated_pages, dependent: :restrict_with_error has_many :language_attributes, dependent: :restrict_with_error + has_many :resource_scores, dependent: :restrict_with_error + has_many :resource_default_orders, dependent: :restrict_with_error validates :name, presence: true validates :code, presence: true - validates_with LanguageValidator, on: [:create, :update] + validates_with LanguageValidator, on: %i[create update] end diff --git a/app/models/resource_default_order.rb b/app/models/resource_default_order.rb index 49ad70f7b..8188ae4d6 100644 --- a/app/models/resource_default_order.rb +++ b/app/models/resource_default_order.rb @@ -1,21 +1,39 @@ class ResourceDefaultOrder < ApplicationRecord + MAX_DEFAULT_ORDER_POSITION = 9 belongs_to :resource + belongs_to :language - validates :position, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 1} - validates :resource_id, presence: true, uniqueness: {scope: :lang, message: "should have only one resource per language"} - validates :lang, presence: true + validates :resource_id, presence: true, + uniqueness: {scope: :language_id, + message: "should have only one ResourceDefaultOrder per language"} + validates :language, presence: true + validates :position, presence: true, + numericality: {only_integer: true, greater_than_or_equal_to: 1, + less_than_or_equal_to: MAX_DEFAULT_ORDER_POSITION} + + validate :unique_position_per_language_and_resource_type - before_save :downcase_lang after_commit :clear_resource_cache + after_commit :touch_resource, on: %i[create update] private - def downcase_lang - self.lang = lang.downcase if lang.present? + def unique_position_per_language_and_resource_type + existing = ResourceDefaultOrder.joins(:resource) + .where(language_id: language_id, position: position) + .where(resources: {resource_type_id: resource.resource_type_id}) + .where.not(id:) + return unless existing.exists? + + errors.add(:position, "is already taken for this language and resource type") end def clear_resource_cache Rails.cache.delete_matched("cache::resources/*") Rails.cache.delete_matched("resources/*") end + + def touch_resource + resource&.touch(:updated_at) + end end diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index e3163a5a3..e17db1816 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -1,57 +1,71 @@ # frozen_string_literal: true class ResourceScore < ApplicationRecord - MAX_FEATURED_ORDER_POSITION = 10 + MAX_FEATURED_ORDER_POSITION = 9 + MAX_SCORE = 20 belongs_to :resource + belongs_to :language - validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 20}, allow_nil: true + validates :resource_id, presence: true + validates :country, presence: true + validates :language, presence: true validates :featured_order, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_FEATURED_ORDER_POSITION }, allow_nil: true - validates :resource_id, presence: true - validates :country, presence: true - validates :lang, presence: true - validate :resource_uniquness_per_country_lang_and_resource_type - validate :featured_has_order_assigned - validate :featured_order_is_available_for_country_lang_and_resource_type, if: -> { featured && featured_order.present? } + validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE}, + allow_nil: true + + validate :unique_resource_score_per_country_and_language + validate :unique_featured_order_per_country_language_and_resource_type, if: lambda { + featured && featured_order.present? + } + validate :featured_and_featured_order_consistency - before_save :downcase_country_and_lang + before_save :downcase_country after_commit :clear_resource_cache + after_commit :touch_resource, on: %i[create update] private - def resource_uniquness_per_country_lang_and_resource_type - existing = ResourceScore.joins(:resource).where( - country: country, lang: lang, resource_id: resource_id, resources: {resource_type_id: resource.resource_type_id} - ).where.not(id:) - return unless existing.exists? - - errors.add(:resource_id, "should have only one score per country, language and resource type") - end - - def downcase_country_and_lang + def downcase_country self.country = country.downcase if country.present? - self.lang = lang.downcase if lang.present? end - def featured_has_order_assigned - return unless featured && featured_order.nil? + def unique_resource_score_per_country_and_language + existing = ResourceScore.where( + country: country, + language_id: language_id, + resource_id: resource_id + ).where.not(id:) + return unless existing.exists? - errors.add(:featured_order, "must be present if resource is featured") + errors.add(:resource_id, "should have only one ResourceScore per country and language") end - def featured_order_is_available_for_country_lang_and_resource_type + def unique_featured_order_per_country_language_and_resource_type existing = ResourceScore.joins(:resource) - .where(country: country, lang: lang, featured_order: featured_order) - .where.not(id:) + .where(country: country, language_id: language_id, featured_order: featured_order) .where(resources: {resource_type_id: resource.resource_type_id}) + .where.not(id:) return unless existing.exists? errors.add(:featured_order, "is already taken for this country, language and resource type") end + def featured_and_featured_order_consistency + if featured && featured_order.nil? + errors.add(:featured_order, "must be present if resource is featured") + elsif !featured && featured_order.present? + errors.add(:featured, "must be true if a featured_order is assigned") + end + end + def clear_resource_cache Rails.cache.delete_matched("cache::resources/*") Rails.cache.delete_matched("resources/*") end + + def touch_resource + resource&.touch(:updated_at) + end end diff --git a/app/serializers/resource_default_order_serializer.rb b/app/serializers/resource_default_order_serializer.rb index 52fc923b2..d65c8c050 100644 --- a/app/serializers/resource_default_order_serializer.rb +++ b/app/serializers/resource_default_order_serializer.rb @@ -2,7 +2,13 @@ class ResourceDefaultOrderSerializer < ActiveModel::Serializer type "resource-default-order" - attributes :position, :lang, :created_at, :updated_at + attributes :position, :created_at, :updated_at + attribute :lang belongs_to :resource + belongs_to :language + + def lang + object.language&.code + end end diff --git a/app/serializers/resource_score_serializer.rb b/app/serializers/resource_score_serializer.rb index 665902756..742044d2f 100644 --- a/app/serializers/resource_score_serializer.rb +++ b/app/serializers/resource_score_serializer.rb @@ -2,7 +2,13 @@ class ResourceScoreSerializer < ActiveModel::Serializer type "resource-score" - attributes :featured, :country, :lang, :score, :user_score_average, :user_score_count, :featured_order, :created_at, :updated_at + attributes :featured, :country, :score, :user_score_average, :user_score_count, :featured_order, :created_at, :updated_at + attribute :lang belongs_to :resource + belongs_to :language + + def lang + object.language&.code + end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 477c90ab2..aaf2320de 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,28 @@ { "ignored_warnings": [ + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "0bd6486ea46fa6dbb922f16405e32a2a4bfa40ac96aebb804d96b28b490f3945", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceScore#find`", + "file": "app/controllers/resource_scores_controller.rb", + "line": 39, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceScore.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceScoresController", + "method": "update" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -23,6 +46,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "2fb5ff2fd47e751a6ef49f6b4de92d55d80c2ec6d13da50c2c9f1f833b17e458", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceDefaultOrder#find`", + "file": "app/controllers/resource_default_orders_controller.rb", + "line": 37, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceDefaultOrder.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceDefaultOrdersController", + "method": "update" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -53,7 +99,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceScore#find`", "file": "app/controllers/resources/featured_controller.rb", - "line": 35, + "line": 40, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceScore.find(params[:id])", "render_path": null, @@ -138,6 +184,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "8a99a92cfa46c5cedaf6349c1bc54c065505922ebfc9b7fc2bed934660bd6701", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceScore#find`", + "file": "app/controllers/resource_scores_controller.rb", + "line": 30, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceScore.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceScoresController", + "method": "destroy" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -145,7 +214,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceDefaultOrder#find`", "file": "app/controllers/resources/default_order_controller.rb", - "line": 25, + "line": 29, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceDefaultOrder.find(params[:id])", "render_path": null, @@ -272,6 +341,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "f25882ed7eaf7fb1151c8a23125c104191f95878112eb3c66a7bfd1cb2fa49ec", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceDefaultOrder#find`", + "file": "app/controllers/resource_default_orders_controller.rb", + "line": 28, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceDefaultOrder.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceDefaultOrdersController", + "method": "destroy" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -279,7 +371,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceDefaultOrder#find`", "file": "app/controllers/resources/default_order_controller.rb", - "line": 33, + "line": 37, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceDefaultOrder.find(params[:id])", "render_path": null, @@ -302,7 +394,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceScore#find`", "file": "app/controllers/resources/featured_controller.rb", - "line": 27, + "line": 31, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceScore.find(params[:id])", "render_path": null, @@ -319,5 +411,5 @@ "note": "" } ], - "brakeman_version": "7.1.0" + "brakeman_version": "7.1.1" } diff --git a/config/routes.rb b/config/routes.rb index b27cda484..18a16a1e1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,45 +12,57 @@ # Defines the root path route ("/") # root "posts#index" - resources :systems, only: [:index, :show] + resources :systems, only: %i[index show] resources :languages - resources :resource_types, only: [:index, :show] + resources :resource_types, only: %i[index show] get "resources/suggestions", to: "resources#suggestions" resources :resources do - resources :languages, controller: :resource_languages, only: [:update, :show] - resources :translated_attributes, path: "translated-attributes", only: [:create, :update, :destroy] + resources :languages, controller: :resource_languages, only: %i[update show] + resources :translated_attributes, path: "translated-attributes", only: %i[create update destroy] post "translations/publish", to: "resources#publish_translation" collection do - resources :featured, only: [:index, :create, :update, :destroy], module: :resources do - collection do - put :mass_update - patch :mass_update - end - end - resources :default_order, only: [:index, :create, :update, :destroy], module: :resources + get :featured + get :default_order end end - resources :drafts, only: [:index, :show, :create, :destroy] - resources :translations, only: [:index, :show] - resources :pages, only: [:create, :update, :show] - resources :tips, only: [:create, :update] - resources :custom_pages, only: [:create, :update, :destroy, :show] - resources :custom_tips, only: [:create, :destroy] - resources :attributes, only: [:create, :update, :destroy, :show] - resources :translated_pages, only: [:create, :update, :destroy, :show] + resources :resource_scores, only: %i[index create update destroy] do + collection do + put :mass_update + patch :mass_update + put :mass_update_ranked + patch :mass_update_ranked + end + end + + resources :resource_default_orders, only: %i[index create update destroy] do + collection do + put :mass_update + patch :mass_update + end + end + + resources :drafts, only: %i[index show create destroy] + resources :translations, only: %i[index show] + resources :pages, only: %i[create update show] + resources :tips, only: %i[create update] + resources :custom_pages, only: %i[create update destroy show] + resources :custom_tips, only: %i[create destroy] + + resources :attributes, only: %i[create update destroy show] + resources :translated_pages, only: %i[create update destroy show] resources :views, only: [:create] resources :follow_ups, only: [:create] resources :attachments - resources :auth, only: [:create, :show] + resources :auth, only: %i[create show] - resources :custom_manifests, only: [:create, :update, :destroy, :show] + resources :custom_manifests, only: %i[create update destroy show] - resources :tool_groups, path: "tool-groups", only: [:create, :destroy, :index, :show, :update] do + resources :tool_groups, path: "tool-groups", only: %i[create destroy index show update] do post "tools", to: "tool_groups#create_tool" put "tools/:id", to: "tool_groups#update_tool" delete "tools/:id", to: "tool_groups#delete_tool" @@ -58,17 +70,17 @@ # Rule Languages resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_languages, path: "rules-language", only: [:create, :destroy, :update] + resources :rule_languages, path: "rules-language", only: %i[create destroy update] end # Rule Countries resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_countries, path: "rules-country", only: [:create, :destroy, :update] + resources :rule_countries, path: "rules-country", only: %i[create destroy update] end # Rule Praxis resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_praxes, path: "rules-praxis", only: [:create, :destroy, :update] + resources :rule_praxes, path: "rules-praxis", only: %i[create destroy update] end patch "user/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v5.7.0-v6.0.0 @@ -80,12 +92,12 @@ patch "users/:id", to: "users#update" scope "users/:user_id/relationships" do - resources :favorite_tools, path: "favorite-tools", only: [:index, :create] + resources :favorite_tools, path: "favorite-tools", only: %i[index create] end delete "users/:user_id/relationships/favorite-tools", to: "favorite_tools#destroy" scope "users/:user_id" do - resources :training_tips, path: "training-tips", only: [:create, :update, :destroy] + resources :training_tips, path: "training-tips", only: %i[create update destroy] end get "monitors/commit" @@ -94,7 +106,9 @@ get "analytics/global", to: "global_activity_analytics#show" get "translations/files/:path", - to: redirect("https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302), + to: redirect( + "https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302 + ), format: false, # these next lines are required to have the extension be part of path default: {format: "html"}, constraints: {path: /.*/} @@ -107,6 +121,8 @@ end end + get "content_status", to: "content_status#index" + if Rails.env.production? || Rails.env.staging? Sidekiq::Web.use Rack::Auth::Basic do |username, password| username == ENV.fetch("SIDEKIQ_USERNAME") && password == ENV.fetch("SIDEKIQ_PASSWORD") diff --git a/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb b/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb new file mode 100644 index 000000000..593b79340 --- /dev/null +++ b/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb @@ -0,0 +1,25 @@ +class ConvertResourceScoreLangToLanguageId < ActiveRecord::Migration[7.1] + def up + add_column :resource_scores, :language_id, :integer + add_index :resource_scores, [:language_id, :country] + + # Migrate data from lang to language_id by joining with languages table + execute <<-SQL + UPDATE resource_scores + SET language_id = languages.id + FROM languages + WHERE LOWER(resource_scores.lang) = LOWER(languages.code) + SQL + + remove_index :resource_scores, [:lang, :country] if index_exists?(:resource_scores, [:lang, :country]) + remove_column :resource_scores, :lang if column_exists?(:resource_scores, :lang) + end + + def down + remove_index :resource_scores, [:language_id, :country] if index_exists?(:resource_scores, [:language_id, :country]) + remove_column :resource_scores, :language_id, :integer if column_exists?(:resource_scores, :language_id) + + add_column :resource_scores, :lang + add_index :resource_scores, [:lang, :country] + end +end diff --git a/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb b/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb new file mode 100644 index 000000000..09aca58bb --- /dev/null +++ b/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb @@ -0,0 +1,29 @@ +class ConvertResourceDefaultOrderLangToLanguageId < ActiveRecord::Migration[7.1] + def up + add_column :resource_default_orders, :language_id, :integer + add_index :resource_default_orders, :language_id + + # Migrate data from lang to language_id by joining with languages table + execute <<-SQL + UPDATE resource_default_orders + SET language_id = languages.id + FROM languages + WHERE LOWER(resource_default_orders.lang) = LOWER(languages.code) + SQL + + if column_exists?(:resource_default_orders, :lang) + remove_index :resource_default_orders, :lang + remove_column :resource_default_orders, :lang + end + end + + def down + if column_exists?(:resource_default_orders, :language_id) + remove_index :resource_default_orders, :language_id + remove_column :resource_default_orders, :language_id, :integer + end + + add_column :resource_default_orders, :lang + add_index :resource_default_orders, :lang + end +end diff --git a/db/schema.rb b/db/schema.rb index 094efeac4..dc9dd2f32 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_10_22_202102) do +ActiveRecord::Schema[7.1].define(version: 2025_11_24_224217) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -185,10 +185,10 @@ create_table "resource_default_orders", force: :cascade do |t| t.integer "position" t.integer "resource_id" - t.string "lang" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["lang"], name: "index_resource_default_orders_on_lang" + t.integer "language_id" + t.index ["language_id"], name: "index_resource_default_orders_on_language_id" t.index ["resource_id"], name: "index_resource_default_orders_on_resource_id" end @@ -196,14 +196,14 @@ t.integer "resource_id" t.boolean "featured" t.string "country" - t.string "lang" t.integer "score" t.float "user_score_average" t.integer "user_score_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "featured_order" - t.index ["lang", "country"], name: "index_resource_scores_on_lang_and_country" + t.integer "language_id" + t.index ["language_id", "country"], name: "index_resource_scores_on_language_id_and_country" t.index ["resource_id"], name: "index_resource_scores_on_resource_id" end diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb new file mode 100644 index 000000000..26e69bee7 --- /dev/null +++ b/spec/acceptance/content_status_controller_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "acceptance_helper" +require "sidekiq/testing" + +resource "ContentStatus" do + include ActiveJob::TestHelper + + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" + + get "content_status" do + let!(:resource_type_tract) { ResourceType.find_or_create_by!(name: "tract", dtd_file: "tract.xsd") } + let!(:resource_type_lesson) { ResourceType.find_or_create_by!(name: "lesson", dtd_file: "lesson.xsd") } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:tract_resource) do + Resource.create!(name: "Test Tract", resource_type: resource_type_tract, system: System.first, + abbreviation: "test-tract") + end + let!(:lesson_resource) do + Resource.create!(name: "Test Lesson", resource_type: resource_type_lesson, system: System.first, + abbreviation: "test-lesson") + end + let!(:resource_score_tract) do + ResourceScore.create!( + resource: tract_resource, + country: "us", + language: language_en, + featured: true, + featured_order: 1, + score: 5 + ) + end + let!(:resource_score_lesson) do + ResourceScore.create!( + resource: lesson_resource, + country: "us", + language: language_en, + featured: false, + featured_order: nil, + score: 3 + ) + end + let!(:resource_default_order) do + ResourceDefaultOrder.create!( + resource: tract_resource, + language: language_en, + position: 1 + ) + end + + it "returns statistics JSON with metrics by resource type and country/language breakdown" do + do_request + + expect(status).to be(200) + json = JSON.parse(response_body) + + expect(json).to have_key("tools") + expect(json).to have_key("lessons") + expect(json).to have_key("countries") + + expect(json["tools"]).to include("default", "featured", "ranked", "total") + expect(json["lessons"]).to include("default", "featured", "ranked", "total") + + expect(json["countries"]).to be_an(Array) + expect(json["countries"][0]).to include("country_code", "languages") + + country_data = json["countries"].find { |c| c["country_code"] == "us" } + expect(country_data).not_to be_nil + + language_data = country_data["languages"].find { |l| l["language_code"] == "en" } + expect(language_data).to include( + "language_code", + "language_name", + "lessons", + "tools", + "last_updated" + ) + end + + context "when an error occurs" do + before do + allow(Language).to receive(:joins).and_raise("Something went wrong") + end + + it "returns unprocessable entity" do + do_request + + expect(status).to eq(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + end +end diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb new file mode 100644 index 000000000..ab2d12d55 --- /dev/null +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -0,0 +1,515 @@ +# frozen_string_literal: true + +require "acceptance_helper" +require "sidekiq/testing" + +resource "ResourceDefaultOrders" do + include ActiveJob::TestHelper + + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" + let(:raw_post) { params.to_json } + let(:authorization) { AuthToken.generic_token } + + # Seeded 'tract' resources + let!(:resource) { Resource.find_by!(name: "Knowing God Personally") } + let!(:resource2) { Resource.find_by!(name: "Knowing God Personally Variant") } + let!(:resource3) { Resource.find_by!(name: "Satisfied?") } + + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } + + before(:each) do + ResourceDefaultOrder.delete_all + end + + get "resource_default_orders" do + before do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + FactoryBot.create(:resource_default_order, resource: resource2, language: language_en) + end + + context "without filters" do + it "returns default order resources" do + do_request include: "resource-default-orders" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + end + end + + context "with language filter" do + it "returns default order resources for specified language" do + do_request lang: "fr" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "with language not found" do + it "returns an error" do + do_request lang: "invalid_lang" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + + context "inside filter param" do + it "returns default order resources for specified language" do + do_request filter: {lang: "fr"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "with language not found" do + it "returns an error" do + do_request filter: {lang: "invalid_lang"} + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + end + + context "with a different capitalization" do + it "returns default order resources for specified language" do + do_request filter: {lang: "am"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + + context "with non-existent language" do + it "returns an error" do + do_request lang: "non_existent_lang" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + + context "with non-existent language inside filter param" do + it "returns an error" do + do_request filter: {lang: "non_existent_lang"} + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + end + + context "with resource_type filter" do + let(:resource_type) { resource.resource_type } + + it "returns default order resources for specified resource type" do + do_request resource_type: resource_type.name.downcase + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + end + + context "inside filter param" do + it "returns default order resources for specified resource type" do + do_request filter: {resource_type: resource_type.name.downcase} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + end + end + end + end + + post "resource_default_orders" do + requires_authorization + + let(:valid_params) do + { + data: { + type: "resource_default_order", + attributes: { + resource_id: resource.id, + lang: language_en.code, + position: 2 + } + } + } + end + + context "with valid parameters" do + it "creates a new default order resource" do + do_request(valid_params) + + expect(status).to be(201) + json = JSON.parse(response_body) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_default_order", + attributes: {resource_id: resource.id, position: "invalid"}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + end + + delete "resource_default_orders/:id" do + requires_authorization + + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + end + let(:id) { resource_default_order.id } + + it "deletes the default order resource" do + do_request + + expect(status).to be(200) + expect(ResourceDefaultOrder.exists?(id)).to be false + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns not found" do + do_request + + expect(status).to be(404) + end + end + end + + patch "resource_default_orders/:id" do + requires_authorization + + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + end + let(:id) { resource_default_order.id } + let(:valid_update_params) do + { + data: { + type: "resource_default_order", + attributes: { + lang: language_fr.code + } + } + } + end + + context "with valid parameters" do + it "updates the default order resource" do + do_request(valid_update_params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]["relationships"]["language"]["data"]["id"]).to eq(language_fr.id.to_s) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_default_order", attributes: {position: "invalid"}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns not found" do + do_request(valid_update_params) + + expect(status).to be(404) + end + end + end + + patch "resource_default_orders/mass_update" do + requires_authorization + + let(:lang) { "en" } + let(:resource_ids) { [] } + let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:params) do + {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type&.name}}} + end + + context "with no lang param" do + let(:lang) { nil } + + context "when sending an empty array" do + it "raises an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "raises an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") + end + end + end + + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") + end + end + end + + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is not supported") + end + end + + context "with lang param" do + context "with no previous resource default order" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending an array of strings" do + let(:resource_ids) { [resource.id.to_s, resource2.id.to_s] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is expected to be an array of integers") + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "returns an array with 1 resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + end + end + + context "when sending more than 1 resource default order" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with more than 1 resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + + context "when sending duplicate ids" do + let(:resource_ids) { [resource.id, resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end + end + end + + context "with previous resource default orders" do + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en, position: 1) + end + let!(:resource_default_order2) do + FactoryBot.create(:resource_default_order, resource: resource2, language: language_en, position: 2) + end + + context "when sending an empty array" do + let(:resource_ids) { [] } + + it "deletes all matching resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + expect(ResourceDefaultOrder.exists?(resource_default_order.id)).to be false + expect(ResourceDefaultOrder.exists?(resource_default_order2.id)).to be false + end + end + + context "when sending 1 resource to replace" do + let(:resource_ids) { [resource3.id, resource2.id] } + + it "returns an array with the replaced resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + end + + context "when sending more than 1 resource to replace" do + let(:resource_ids) { [resource2.id, resource3.id, resource.id] } + + it "returns an array with the replaced resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][2]["attributes"]["position"]).to eq(3) + end + end + + context "when sending the same resource to replace" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with the resource default orders in new positions" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + end + + context "when omitting a resource from the incoming list" do + let(:resource_ids) { [resource.id] } + + it "removes the omitted resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(ResourceDefaultOrder.exists?(resource_default_order2.id)).to be false + end + end + + context "when omitting multiple resources from the incoming list" do + let!(:resource_default_order3) do + FactoryBot.create(:resource_default_order, resource: resource3, language: language_en, position: 3) + end + let(:resource_ids) { [resource2.id] } + + it "removes all omitted resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(ResourceDefaultOrder.exists?(resource_default_order.id)).to be false + expect(ResourceDefaultOrder.exists?(resource_default_order3.id)).to be false + end + end + end + end + end +end diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb new file mode 100644 index 000000000..ecf103ad9 --- /dev/null +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -0,0 +1,920 @@ +# frozen_string_literal: true + +require "acceptance_helper" +require "sidekiq/testing" + +resource "ResourceScores" do + include ActiveJob::TestHelper + + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" + let(:raw_post) { params.to_json } + let(:authorization) { AuthToken.generic_token } + + # Seeded 'tract' resources + let!(:resource) { Resource.find_by!(name: "Knowing God Personally") } + let!(:resource2) { Resource.find_by!(name: "Knowing God Personally Variant") } + let!(:resource3) { Resource.find_by!(name: "Satisfied?") } + + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } + + get "resource_scores" do + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + context "without filters" do + it "returns resource scores" do + do_request include: "resource" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with language filter" do + it "returns resource scores for specified language" do + do_request lang: "fr" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns resource scores for specified language" do + do_request filter: {lang: "fr"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + + context "with a different capitalization" do + it "returns default order resources for specified language" do + do_request filter: {lang: "am"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + end + + context "with country filter" do + it "returns resource scores for specified country" do + do_request country: "us" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + + context "inside filter param" do + it "returns resource scores for specified country" do + do_request filter: {country: "us"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_score) do + FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, + language: language_en) + end + + it "returns resource scores for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(tool_resource.id.to_s) + end + + context "inside filter param" do + it "returns resource scores for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(tool_resource.id.to_s) + end + end + end + end + + post "resource_scores" do + requires_authorization + + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + let(:valid_params) do + { + data: { + type: "resource_score", + attributes: { + resource_id: resource.id, + lang: language_en.code, + country: "US", + featured: true, + featured_order: 1 + } + } + } + end + + context "with valid parameters" do + it "creates a new resource score" do + do_request(valid_params) + + expect(status).to be(201) + json = JSON.parse(response_body) + expect(json["data"]["attributes"]["featured"]).to be true + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_score", attributes: {featured: true, resource_id: resource.id}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + end + + delete "resource_scores/:id" do + requires_authorization + + let(:id) { resource_score.id } + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + it "deletes the resource score" do + do_request + + expect(status).to be(200) + expect(ResourceScore.exists?(id)).to be false + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns a not found error" do + do_request + + expect(status).to be(404) + end + end + end + + patch "resource_scores/:id" do + requires_authorization + + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + let(:id) { resource_score.id } + let(:valid_update_params) do + { + data: { + type: "resource_score", + attributes: { + featured_order: 2, + country: "CA" + } + } + } + end + + context "with valid parameters" do + it "updates the resource score" do + do_request(valid_update_params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]["attributes"]["featured-order"]).to eq(2) + expect(json["data"]["attributes"]["country"]).to eq("CA".downcase) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + + context "when updating the language" do + let(:lang_update_params) do + { + data: { + type: "resource_score", + attributes: { + lang: language_fr.code + } + } + } + end + + it "updates the resource score language" do + do_request(lang_update_params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]["relationships"]["language"]["data"]["id"]).to eq(language_fr.id.to_s) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_score", attributes: {featured_order: "invalid"}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns a not found error" do + do_request(valid_update_params) + + expect(status).to be(404) + end + end + end + + patch "resource_scores/mass_update" do + requires_authorization + + let(:country) { "US" } + let(:lang) { "en" } + let(:resource_ids) { [] } + let(:resource_type) { ResourceType.find_by!(name: "tract") } + let(:featured) { true } + let(:params) do + {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, + resource_type: resource_type&.name}}} + end + + context "with no country and lang params" do + let(:country) { nil } + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with no country, lang, and resource_type params" do + let(:country) { nil } + let(:lang) { nil } + let(:resource_type_attr) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + end + + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is not supported") + end + end + + context "with country and lang params" do + context "with no previous resource score" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending an array of strings" do + let(:resource_ids) { [resource.id.to_s, resource2.id.to_s] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is expected to be an array of integers") + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an array with 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when sending more than 1 resource score" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with more than 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + + context "when sending duplicate ids" do + let(:resource_ids) { [resource.id, resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end + + context "when sending resource ids which represent resources of different types" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("do not match") + end + end + end + end + + context "with previous resource scores" do + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, + featured_order: 1) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, + featured_order: nil) + end + + context "when sending an empty array" do + let(:resource_ids) { [] } + + it "deletes all matching resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + + context "when a resource has a score" do + let(:resource_ids) { [] } + + before do + resource_score.update!(score: 5) + end + + it "removes featured status but keeps the score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]).to be_empty + + resource_score.reload + + expect(resource_score.featured).to be false + expect(resource_score.featured_order).to be_nil + expect(resource_score.score).to eq(5) + end + end + end + + context "when sending 1 resource to replace" do + let(:resource_ids) { [resource3.id, resource2.id] } + + it "returns an array with the replaced resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + end + + context "when sending more than 1 resource to replace" do + let(:resource_ids) { [resource2.id, resource3.id, resource.id] } + + it "returns an array with the replaced resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["featured"]).to eq(true) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when sending the same resource to replace" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with the replaced resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + end + + context "when omitting a resource from the incoming list" do + let(:resource_ids) { [resource.id] } + + it "soft-deletes the omitted resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(ResourceScore.exists?(resource_score2.id)).to be false + end + end + + context "when omitting a resource with a score from the incoming list" do + let(:resource_ids) { [resource2.id] } + + before do + resource_score.update!(score: 10) + end + + it "removes featured status but keeps the score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + + resource_score.reload + expect(resource_score.featured).to be false + expect(resource_score.featured_order).to be_nil + expect(resource_score.score).to eq(10) + end + end + + context "when omitting multiple resources from the incoming list" do + let!(:resource_score3) do + ResourceScore.create!(resource: resource3, country: country, language: language_en, featured: true, + featured_order: 3) + end + let(:resource_ids) { [resource2.id] } + + it "soft-deletes all omitted resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(ResourceScore.exists?(resource_score.id)).to be false + expect(ResourceScore.exists?(resource_score3.id)).to be false + end + end + end + end + end + + patch "resource_scores/mass_update_ranked" do + requires_authorization + + let(:country) { "US" } + let(:lang) { "en" } + let(:ranked_resources) { [] } + let(:resource_type) { ResourceType.find_by!(name: "tract") } + let(:params) do + {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, + resource_type: resource_type&.name}}} + end + + context "with no country and lang params" do + let(:country) { nil } + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + end + + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("not supported") + end + end + + context "with country and lang params" do + context "with no previous resource scores" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an array with 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(10) + end + end + + context "when sending more than 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } + + context "when sending duplicate ids" do + let(:ranked_resources) { [{resource_id: resource.id, score: 6}, {resource_id: resource.id, score: 7}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end + + it "returns an array with more than 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["score"]).to eq(10) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + end + + context "with previous resource scores" do + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, score: 5) + end + + context "when sending an empty array" do + let(:ranked_resources) { [] } + + it "clears all scores for matching resource scores" do + do_request(params) + + expect(status).to be(200) + + json = JSON.parse(response_body) + expect(json["data"]).to be_empty + end + + context "when existing scores are featured" do + before do + resource_score.update!(featured: true, featured_order: 1) + resource_score2.update!(featured: true, featured_order: 2) + end + + it "soft-deletes the scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]).to be_empty + + resource_score.reload + resource_score2.reload + + expect(resource_score.score).to be_nil + expect(resource_score.featured).to be true + expect(resource_score.featured_order).to eq(1) + + expect(resource_score2.score).to be_nil + expect(resource_score2.featured).to be true + expect(resource_score2.featured_order).to eq(2) + end + end + end + + context "when sending 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 20}] } + + it "returns an array with the updated resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + end + + it "updates only the existing resource score" do + do_request(params) + + resource_score.reload + expect(resource_score.score).to eq(20) + end + end + + context "when sending more than 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 18}, {resource_id: resource2.id, score: 12}] } + + it "returns an array with the updated resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(18) + expect(json["data"][1]["attributes"]["score"]).to eq(12) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when sending a new resource to add" do + let(:ranked_resources) { [{resource_id: resource3.id, score: 16}] } + + it "creates a new resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(16) + end + end + + context "when sending a mix of existing and new resources" do + let(:ranked_resources) { [{resource_id: resource.id, score: 19}, {resource_id: resource3.id, score: 14}] } + + it "returns an array with updated and new resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(19) + expect(json["data"][1]["attributes"]["score"]).to eq(14) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when resource_type filter is applied" do + let(:ranked_resources) { [{resource_id: resource.id, score: 17}] } + + it "only updates resources of the specified type" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when updating with different scores" do + let(:ranked_resources) do + [ + {resource_id: resource.id, score: 20}, + {resource_id: resource2.id, score: 13}, + {resource_id: resource3.id, score: 7} + ] + end + + it "returns all resources sorted by score in descending order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["attributes"]["score"]).to eq(13) + expect(json["data"][2]["attributes"]["score"]).to eq(7) + end + end + end + end + end +end diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb deleted file mode 100644 index 0d1a5afaa..000000000 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require "acceptance_helper" -require "sidekiq/testing" - -resource "Resources::DefaultOrder" do - include ActiveJob::TestHelper - - header "Accept", "application/vnd.api+json" - header "Content-Type", "application/vnd.api+json" - let(:raw_post) { params.to_json } - let(:authorization) { AuthToken.generic_token } - - let!(:resource) { Resource.first } - let!(:other_resource) { Resource.last } - - before(:each) do - ResourceDefaultOrder.delete_all - end - - get "resources/default_order" do - before do - FactoryBot.create(:resource_default_order, resource: resource, lang: "en") - FactoryBot.create(:resource_default_order, resource: other_resource, lang: "en") - end - - context "without filters" do - it "returns default order resources" do - do_request include: "resource-default-orders" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(2) - end - end - - context "with language filter" do - it "returns default order resources for specified language" do - do_request lang: "fr" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - - context "inside filter param" do - it "returns default order resources for specified language" do - do_request filter: {lang: "fr"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - end - end - - context "with resource_type filter" do - let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } - let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } - let!(:tool_order) { FactoryBot.create(:resource_default_order, resource: tool_resource, lang: "en") } - - it "returns default order resources for specified resource type" do - do_request resource_type: "metatool" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - end - - context "inside filter param" do - it "returns default order resources for specified resource type" do - do_request filter: {resource_type: "metatool"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - end - end - end - end - - post "resources/default_order" do - requires_authorization - - let(:valid_params) do - { - data: { - type: "resource_default_order", - attributes: { - resource_id: resource.id, - lang: "en", - position: 2 - } - } - } - end - - context "with valid parameters" do - it "creates a new default order resource" do - do_request(valid_params) - - expect(status).to be(201) - json = JSON.parse(response_body) - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {attributes: {type: "resource_default_order"}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - end - - delete "resources/default_order/:id" do - requires_authorization - - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, lang: "en") } - let(:id) { resource_default_order.id } - - it "deletes the default order resource" do - do_request - - expect(status).to be(200) - expect(ResourceDefaultOrder.exists?(id)).to be false - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns unprocessable entity" do - do_request - - expect(status).to be(404) - end - end - end - - patch "resources/default_order/:id" do - requires_authorization - - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, lang: "en") } - let(:id) { resource_default_order.id } - let(:valid_update_params) do - { - data: { - type: "resource_default_order", - attributes: { - lang: "fr" - } - } - } - end - - context "with valid parameters" do - it "updates the default order resource" do - do_request(valid_update_params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"]["attributes"]["lang"]).to eq("fr") - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {type: "resource_default_order", attributes: {position: "invalid"}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns unprocessable entity" do - do_request(valid_update_params) - - expect(status).to be(404) - end - end - end -end diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb deleted file mode 100644 index b7e3000c8..000000000 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ /dev/null @@ -1,399 +0,0 @@ -# frozen_string_literal: true - -require "acceptance_helper" -require "sidekiq/testing" - -resource "Resources::Featured" do - include ActiveJob::TestHelper - - header "Accept", "application/vnd.api+json" - header "Content-Type", "application/vnd.api+json" - let(:raw_post) { params.to_json } - let(:authorization) { AuthToken.generic_token } - - let!(:resource) { Resource.first } - let!(:unfeatured_resource) { Resource.last } - - get "resources/featured" do - let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| - rs.featured = true - rs.featured_order = 1 - end - } - - context "without filters" do - it "returns featured resources" do - do_request include: "resource-score" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - end - - context "with language filter" do - it "returns featured resources for specified language" do - do_request lang: "fr" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - - context "inside filter param" do - it "returns featured resources for specified language" do - do_request filter: {lang: "fr"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - end - end - - context "with country filter" do - it "returns featured resources for specified country" do - do_request country: "us" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - - context "inside filter param" do - it "returns featured resources for specified country" do - do_request filter: {country: "us"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - end - end - - context "with resource_type filter" do - let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } - let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } - let!(:tool_score) { FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2) } - - it "returns featured resources for specified resource type" do - do_request resource_type: "metatool" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) - end - - context "inside filter param" do - it "returns featured resources for specified resource type" do - do_request filter: {resource_type: "metatool"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) - end - end - end - end - - post "resources/featured" do - requires_authorization - - let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| - rs.featured = true - rs.featured_order = 1 - end - } - let(:valid_params) do - { - data: { - type: "resource_score", - attributes: { - resource_id: resource.id, - lang: "en", - country: "US", - featured: true, - featured_order: 1 - } - } - } - end - - context "with valid parameters" do - it "creates a new featured resource score" do - do_request(valid_params) - - expect(status).to be(201) - json = JSON.parse(response_body) - expect(json["data"]["attributes"]["featured"]).to be true - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {type: "resource_score", attributes: {featured: true, resource_id: resource.id}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - end - - delete "resources/featured/:id" do - requires_authorization - - let(:id) { resource_score.id } - let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| - rs.featured = true - rs.featured_order = 1 - end - } - - it "deletes the featured resource score" do - do_request - - expect(status).to be(200) - expect(ResourceScore.exists?(id)).to be false - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns a not found error" do - do_request - - expect(status).to be(404) - end - end - end - - patch "resources/featured/:id" do - requires_authorization - - let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| - rs.featured = true - rs.featured_order = 1 - end - } - let(:id) { resource_score.id } - let(:valid_update_params) do - { - data: { - type: "resource_score", - attributes: { - featured_order: 2, - country: "CA" - } - } - } - end - - context "with valid parameters" do - it "updates the featured resource score" do - do_request(valid_update_params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"]["attributes"]["featured-order"]).to eq(2) - expect(json["data"]["attributes"]["country"]).to eq("CA".downcase) - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {type: "resource_score", attributes: {featured_order: "invalid"}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns a not found error" do - do_request(valid_update_params) - - expect(status).to be(404) - end - end - end - - patch "resources/featured/mass_update" do - requires_authorization - - let(:country) { "US" } - let(:lang) { "en" } - let(:resource_ids) { [] } - let(:resource_type) { ResourceType.find(resource.resource_type_id) } - let(:featured) { true } - let(:params) { {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} } - - context "with no country and lang params" do - let(:country) { nil } - let(:lang) { nil } - - context "when sending an empty array" do - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - - context "when sending 1 resource score" do - let(:resource_ids) { [resource.id] } - - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - end - - context "with country and lang params" do - context "with no previous resource score" do - context "when sending an empty array" do - it "returns an empty array" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(0) - end - end - - context "when sending 1 resource score" do - let(:resource_ids) { [resource.id] } - - it "returns an array with 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "when sending more than 1 resource score" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let(:resource_ids) { [resource.id, resource2.id] } - - it "returns an array with more than 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - end - - context "with previous resource scores" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } - let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, lang: lang, featured: true, featured_order: 1) - end - let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, lang: lang, featured: false, featured_order: nil) - end - - context "when sending an empty array" do - let(:resource_ids) { [] } - - it "deletes all matching resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(0) - end - - context "when a resource has a score" do - let(:resource_ids) { [] } - - before do - resource_score.update!(score: 5) - end - - it "removes featured status but keeps the score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - - resource_score.reload - expect(resource_score.featured).to be false - expect(resource_score.featured_order).to be_nil - expect(resource_score.score).to eq(5) - end - end - end - - context "when sending 1 resource to replace" do - let(:resource_ids) { [resource3.id, resource2.id] } - - it "returns an array with the replaced resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - - context "when sending more than 1 resource to replace" do - let(:resource_ids) { [resource2.id, resource3.id, resource.id] } - - it "returns an array with the replaced resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(3) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - expect(json["data"][0]["attributes"]["featured"]).to eq(true) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) - expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "when sending the same resource to replace" do - let(:resource_ids) { [resource.id, resource2.id] } - - it "returns an array with the replaced resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - end - end - end -end diff --git a/spec/acceptance/resources_controller_spec.rb b/spec/acceptance/resources_controller_spec.rb index f4339360d..718c2a013 100644 --- a/spec/acceptance/resources_controller_spec.rb +++ b/spec/acceptance/resources_controller_spec.rb @@ -19,9 +19,9 @@ let(:resource_4) { Resource.find(4) } let(:resource_5) { Resource.find(5) } - let(:languages_fr_en) { ["fr", "en"] } - let(:languages_fr_it) { ["fr", "it"] } - let(:languages_fr_es) { ["fr", "es"] } + let(:languages_fr_en) { %w[fr en] } + let(:languages_fr_it) { %w[fr it] } + let(:languages_fr_es) { %w[fr es] } let(:languages_fr) { ["fr"] } let(:languages_it) { ["it"] } let(:languages_en) { ["en"] } @@ -30,8 +30,8 @@ let(:countries_fr) { ["FR"] } let(:countries_gb) { ["GB"] } let(:countries_nz) { ["NZ"] } - let(:countries_fr_us) { ["FR", "US"] } - let(:countries_gb_us_nz) { ["GB", "US", "NZ"] } + let(:countries_fr_us) { %w[FR US] } + let(:countries_gb_us_nz) { %w[GB US NZ] } let(:openness_1) { [1] } let(:openness_2) { [2] } @@ -47,7 +47,12 @@ let(:confidence_2_3) { [2, 3] } let(:confidence_3_4) { [3, 4] } - let(:resources_result) { ["metatool", "Knowing God Personally", "Questions About God", "Satisfied?", "Knowing God Personally Variant"].sort } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + + let(:resources_result) do + ["metatool", "Knowing God Personally", "Questions About God", "Satisfied?", "Knowing God Personally Variant"].sort + end get "resources/suggestions" do before(:each) do @@ -136,8 +141,12 @@ context "when matching a tool group with :include in params" do let!(:translation) { Resource.first.latest_translations.first } - let!(:translation_attribute_1) { FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) } - let!(:translation_attribute_2) { FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) } + let!(:translation_attribute_1) do + FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) + end + let!(:translation_attribute_2) do + FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) + end it "return suggested resources with :include and :fields key values for attributes expecified" do delete_all_rules @@ -159,7 +168,8 @@ it "return suggested resources ordered" do delete_all_rules - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -194,15 +204,21 @@ ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_two.id, suggestions_weight: 1.3) ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_two.id, suggestions_weight: 1.0) - ResourceToolGroup.create!(resource_id: resource_1.id, tool_group_id: tool_group_three.id, suggestions_weight: 2.0) - ResourceToolGroup.create!(resource_id: resource_2.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.5) - ResourceToolGroup.create!(resource_id: resource_3.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.1) - ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.0) - ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.2) + ResourceToolGroup.create!(resource_id: resource_1.id, tool_group_id: tool_group_three.id, + suggestions_weight: 2.0) + ResourceToolGroup.create!(resource_id: resource_2.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.5) + ResourceToolGroup.create!(resource_id: resource_3.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.1) + ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.0) + ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.2) end it "return suggested resources ordered" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 # Result ordered # ---------------------------------- @@ -246,7 +262,8 @@ end it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) @@ -258,7 +275,8 @@ context "plus matching languages with negative rule as false" do it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -320,7 +338,8 @@ context "plus matching openness" do context "with negative rule as false" do it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -363,7 +382,8 @@ end it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_es, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_es, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -512,13 +532,18 @@ expect(status).to be(200) json = JSON.parse(response_body) metatool = json["data"].detect { |entry| entry["attributes"]["resource-type"] == "metatool" } - expect(metatool["relationships"]["variants"]["data"]).to match_array([{"id" => "1", "type" => "resource"}, {"id" => "5", "type" => "resource"}]) + expect(metatool["relationships"]["variants"]["data"]).to match_array([{"id" => "1", "type" => "resource"}, + {"id" => "5", "type" => "resource"}]) end context "with translation attributes" do let!(:translation) { Resource.first.latest_translations.first } - let!(:translation_attribute_1) { FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) } - let!(:translation_attribute_2) { FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) } + let!(:translation_attribute_1) do + FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) + end + let!(:translation_attribute_2) do + FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) + end it "get all resources, include latest-translations" do do_request "filter[system]": "GodTools", include: "latest-translations" @@ -536,7 +561,7 @@ expect(status).to be(200) data = JSON.parse(response_body)["data"][0] - expect(data["attributes"].keys).to match_array(["name", "attr-banner-image", "resource-type", "total-views"]) + expect(data["attributes"].keys).to match_array(%w[name attr-banner-image resource-type total-views]) expect(data["relationships"].keys).to eq ["system"] end @@ -560,6 +585,245 @@ end end + get "resources/featured" do + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource_1, country: "us", + language: Language.find_or_create_by!(code: "en", name: "English")) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + context "without filters" do + it "returns featured resources" do + do_request include: "resource-score" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + end + + context "with language filter" do + it "returns featured resources for specified language" do + do_request lang: "fr" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns featured resources for specified language" do + do_request filter: {lang: "fr"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + end + + context "with country filter" do + it "returns featured resources for specified country" do + do_request country: "us" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + + context "inside filter param" do + it "returns featured resources for specified country" do + do_request filter: {country: "us"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_score) do + FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, + language: Language.find_or_create_by!(code: "en", name: "English")) + end + + it "returns featured resources for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) + end + + context "inside filter param" do + it "returns featured resources for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) + end + end + end + end + + get "resources/default_order" do + let!(:resource_default_order_1) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_1, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 1 + end + end + + let!(:resource_default_order_2) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_2, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 2 + end + end + + context "without filters" do + it "returns default order resources" do + do_request include: "language" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + expect(json["data"][1]["id"]).to eq(resource_2.id.to_s) + end + end + + context "with language filter" do + it "returns default order resources for specified language" do + do_request lang: "en" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + end + + it "returns empty array for non-existent language" do + do_request lang: "de" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns default order resources for specified language" do + do_request filter: {lang: "en"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + end + end + + context "with case insensitive language code" do + it "returns default order resources" do + do_request lang: "EN" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_default_order) do + ResourceDefaultOrder.find_or_create_by!(resource: tool_resource, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 3 + end + end + + it "returns default order resources for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + + context "inside filter param" do + it "returns default order resources for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + end + end + + context "with language and resource_type filters" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_default_order) do + ResourceDefaultOrder.find_or_create_by!(resource: tool_resource, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 3 + end + end + + it "returns default order resources matching both filters" do + do_request lang: "en", resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + end + + context "with invalid language code" do + it "returns unprocessable content error" do + do_request lang: "invalid_lang_code_that_does_not_exist" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"]).to be_present + expect(json["errors"].first["detail"]).to include("Language not found") + end + end + + context "returns resources in correct order" do + let!(:resource_default_order_3) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_3, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 1 + end + end + + it "orders by position ascending" do + do_request lang: "en" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(3) + expect(json["data"][0]["id"]).to eq(resource_3.id.to_s) + expect(json["data"][1]["id"]).to eq(resource_1.id.to_s) + expect(json["data"][2]["id"]).to eq(resource_2.id.to_s) + end + end + end + get "resources/:id" do let(:id) { 1 } @@ -640,7 +904,9 @@ expect(JSON.parse(response_body)["data"]).not_to be_nil end context "attribute present" do - let!(:something_else_attribute) { FactoryBot.create(:attribute, resource_id: id, key: "something_else", value: "current_value") } + let!(:something_else_attribute) do + FactoryBot.create(:attribute, resource_id: id, key: "something_else", value: "current_value") + end it "updates resource and updates existing attributes" do do_request data: {type: :resource, attributes: {:description => "hello, world", :"attr-language-attribute" => "language_value", @@ -705,7 +971,10 @@ end context "unpublished translation exists already" do - let!(:translation) { FactoryBot.create(:translation, version: 10, resource_id: resource_id, language_id: language.id, is_published: false) } + let!(:translation) do + FactoryBot.create(:translation, version: 10, resource_id: resource_id, language_id: language.id, + is_published: false) + end it "reuses an existing translation and publishes the resource" do expect do @@ -735,9 +1004,7 @@ def delete_all_rules def resources_matched(data) resources = [] data.select do |element| - if element["attributes"] && element["attributes"]["name"] - resources << element["attributes"]["name"] - end + resources << element["attributes"]["name"] if element["attributes"] && element["attributes"]["name"] end resources.sort end diff --git a/spec/factories/languages.rb b/spec/factories/languages.rb new file mode 100644 index 000000000..b14d72856 --- /dev/null +++ b/spec/factories/languages.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :language do + name { "English" } + code { "en" } + direction { "ltr" } + force_language_name { false } + end +end diff --git a/spec/factories/resource_default_orders.rb b/spec/factories/resource_default_orders.rb index 4938a1741..107c725ab 100644 --- a/spec/factories/resource_default_orders.rb +++ b/spec/factories/resource_default_orders.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :resource_default_order do resource - sequence(:position) { |n| n } - lang { "en" } + language { Language.find_by(code: "en") || FactoryBot.create(:language, code: "en") } + sequence(:position) { |n| (n % 9) + 1 } end end diff --git a/spec/factories/resource_scores.rb b/spec/factories/resource_scores.rb index 6c4ac2452..bb02386da 100644 --- a/spec/factories/resource_scores.rb +++ b/spec/factories/resource_scores.rb @@ -3,10 +3,10 @@ FactoryBot.define do factory :resource_score do resource - featured { false } + language { association :language, code: "en" } + featured { true } featured_order { 1 } country { "us" } - lang { "en" } score { 1 } user_score_average { 1.5 } user_score_count { 1 } diff --git a/spec/factories/resource_types.rb b/spec/factories/resource_types.rb index 79242129b..7717a9620 100644 --- a/spec/factories/resource_types.rb +++ b/spec/factories/resource_types.rb @@ -5,6 +5,11 @@ dtd_file { "tract.xsd" } end + factory :lesson_resource_type do + name { "lesson" } + dtd_file { "lesson.xsd" } + end + factory :article_resource_type do name { "article" } dtd_file { "article.xsd" } diff --git a/spec/models/resource_default_order_spec.rb b/spec/models/resource_default_order_spec.rb index dc91bd7ec..5309542c4 100644 --- a/spec/models/resource_default_order_spec.rb +++ b/spec/models/resource_default_order_spec.rb @@ -1,35 +1,30 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe ResourceDefaultOrder, type: :model do - let(:resource) { Resource.first || FactoryBot.create(:resource) } - subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource) } + let(:resource) { Resource.first } + let(:language) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource, language: language) } describe "validations" do it { is_expected.to be_valid } context "uniqueness validation" do - before { FactoryBot.create(:resource_default_order, resource: resource, lang: "en", position: 1) } + before { FactoryBot.create(:resource_default_order, resource: resource, language: language, position: 1) } - it "validates uniqueness of position scoped to lang" do - duplicate = FactoryBot.build(:resource_default_order, resource: resource, lang: "en", position: 1) + it "validates uniqueness of default order per language" do + duplicate = FactoryBot.build(:resource_default_order, resource: resource, language: language, position: 1) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include("should have only one resource per language") + expect(duplicate.errors[:resource_id]).to include("should have only one ResourceDefaultOrder per language") end it "allows same position for different language" do - different_lang = FactoryBot.build(:resource_default_order, resource: resource, lang: "es", position: 1) + different_lang = FactoryBot.build(:resource_default_order, resource: resource, language: other_language, + position: 1) expect(different_lang).to be_valid end end end - - describe "callbacks" do - context "before_save" do - it "downcases lang" do - resource_default_order.lang = "EN" - resource_default_order.save - expect(resource_default_order.lang).to eq("en") - end - end - end end diff --git a/spec/models/resource_score_spec.rb b/spec/models/resource_score_spec.rb index 0776a8281..915d94278 100644 --- a/spec/models/resource_score_spec.rb +++ b/spec/models/resource_score_spec.rb @@ -4,25 +4,27 @@ RSpec.describe ResourceScore, type: :model do let(:resource) { Resource.first } - subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource) } + let(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource, language: language_en) } describe "validations" do let(:resource_score_with_resource) do FactoryBot.create( - :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", lang: "en" + :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", language: language_en ) end it { is_expected.to be_valid } context "uniqueness validation" do let!(:previous_resource_score) do - FactoryBot.create(:resource_score, resource: resource, country: "us", lang: "en") + FactoryBot.create(:resource_score, resource: resource, country: "us", language: language_en) end - it "validates uniqueness of resource_id scoped to country, lang and resource_type" do - duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", lang: "en") + it "validates uniqueness of resource_id scoped to country and language" do + duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", language: language_en) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include("should have only one score per country, language and resource type") + expect(duplicate.errors[:resource_id]).to include("should have only one ResourceScore per country and language") end end @@ -34,38 +36,53 @@ expect(resource_score.errors[:featured_order]).to include("must be present if resource is featured") end + it "requires featured to be true if featured_order is assigned" do + resource_score.featured = false + resource_score.featured_order = 1 + expect(resource_score).not_to be_valid + expect(resource_score.errors[:featured]).to include("must be true if a featured_order is assigned") + end + it "validates uniqueness of featured_order within country, language and resource type" do resource_score_with_resource - duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", lang: "en") + duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", + language: language_en) expect(duplicate).not_to be_valid expect(duplicate.errors[:featured_order]).to include("is already taken for this country, language and resource type") end context "having a resource score created previously" do let!(:previous_resource_score) do - ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", lang: "en") + ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", + language: language_en) end it "allows same featured_order for different country" do resource2 = Resource.last different_country = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "CA", lang: "en") + country: "CA", language: language_en) expect(different_country).to be_valid end it "allows same featured_order for different language" do resource2 = Resource.last different_lang = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "US", lang: "es") + country: "US", language: other_language) expect(different_lang).to be_valid end end - it "allows same featured_order for different resources" do + it "allows same featured_order for different resource type" do resource_score_with_resource - different_resource = FactoryBot.build(:resource_score, resource: Resource.last, featured: true, - featured_order: 1, country: "US", lang: "en") - expect(different_resource).to be_valid + different_resource_type = FactoryBot.build( + :resource_score, + resource: FactoryBot.create(:resource, resource_type: FactoryBot.create(:lesson_resource_type)), + featured: true, + featured_order: 1, + country: "US", + language: language_en + ) + expect(different_resource_type).to be_valid end end end @@ -77,12 +94,6 @@ resource_score.save expect(resource_score.country).to eq("us") end - - it "downcases lang" do - resource_score.lang = "EN" - resource_score.save - expect(resource_score.lang).to eq("en") - end end end end