From b2bcc3f2bb32b05222ae7e438d2c67417a778262 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Tue, 16 Dec 2025 11:40:40 -0700 Subject: [PATCH 1/2] Add v2 API test coverage This test suite was adapted from the v2 API tests in CDLUC3/dmptool. The original request and view specs were copied and then refactored to align with our v2 API. Supporting factories and spec helper files were updated as needed to accommodate the new coverage. --- spec/factories/identifier_schemes.rb | 14 ++ spec/factories/oauth_access_grants.rb | 36 +++ spec/factories/oauth_access_tokens.rb | 38 ++++ spec/factories/oauth_applications.rb | 25 ++ spec/factories/research_domains.rb | 1 - spec/requests/api/v2/plans_controller_spec.rb | 126 ++++++++++ .../api/v2/templates_controller_spec.rb | 120 ++++++++++ spec/support/helpers/api.rb | 40 ++++ spec/support/mocks/api_v2_json_samples.rb | 215 ++++++++++++++++++ .../_standard_response.json_jbuilder_spec.rb | 184 +++++++++++++++ .../contributors/_show.json.jbuilder_spec.rb | 90 ++++++++ .../v2/datasets/_show.json.jbuilder_spec.rb | 196 ++++++++++++++++ spec/views/api/v2/error.json.jbuilder_spec.rb | 29 +++ .../api/v2/heartbeat.json.jbuilder_spec.rb | 18 ++ .../identifiers/_show.json.jbuilder_spec.rb | 23 ++ .../api/v2/orgs/_show.json.jbuilder_spec.rb | 43 ++++ .../api/v2/plans/_cost.json.jbuilder_spec.rb | 37 +++ .../v2/plans/_funding.json.jbuilder_spec.rb | 58 +++++ .../v2/plans/_project.json.jbuilder_spec.rb | 33 +++ .../api/v2/plans/_show.json.jbuilder_spec.rb | 113 +++++++++ .../api/v2/plans/index.json.jbuilder_spec.rb | 25 ++ .../v2/templates/index.json.jbuilder_spec.rb | 78 +++++++ 22 files changed, 1541 insertions(+), 1 deletion(-) create mode 100644 spec/factories/oauth_access_grants.rb create mode 100644 spec/factories/oauth_access_tokens.rb create mode 100644 spec/factories/oauth_applications.rb create mode 100644 spec/requests/api/v2/plans_controller_spec.rb create mode 100644 spec/requests/api/v2/templates_controller_spec.rb create mode 100644 spec/support/mocks/api_v2_json_samples.rb create mode 100644 spec/views/api/v2/_standard_response.json_jbuilder_spec.rb create mode 100644 spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/error.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/heartbeat.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/plans/_project.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/plans/_show.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/plans/index.json.jbuilder_spec.rb create mode 100644 spec/views/api/v2/templates/index.json.jbuilder_spec.rb diff --git a/spec/factories/identifier_schemes.rb b/spec/factories/identifier_schemes.rb index 3e52d43b66..3e24252c61 100644 --- a/spec/factories/identifier_schemes.rb +++ b/spec/factories/identifier_schemes.rb @@ -32,5 +32,19 @@ identifier_scheme.update("#{identifier_scheme.all_context[idx]}": true) end end + + %i[ + authentication + orgs + plans + users + contributors + identification + research_outputs + ].each do |context| + trait :"for_#{context}" do + add_attribute(:"for_#{context}") { true } + end + end end end diff --git a/spec/factories/oauth_access_grants.rb b/spec/factories/oauth_access_grants.rb new file mode 100644 index 0000000000..2cd8dc8875 --- /dev/null +++ b/spec/factories/oauth_access_grants.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_grants +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# +# Indexes +# +# index_oauth_access_grants_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_grant, class: 'doorkeeper/access_grant' do + token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + redirect_uri { Faker::Internet.url } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb new file mode 100644 index 0000000000..aa94d6b373 --- /dev/null +++ b/spec/factories/oauth_access_tokens.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_tokens +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# refresh_token :string +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# previous_refresh_token :string +# +# Indexes +# +# index_oauth_access_tokens_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_token, class: 'doorkeeper/access_token' do + token { SecureRandom.uuid } + refresh_token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb new file mode 100644 index 0000000000..469dd66269 --- /dev/null +++ b/spec/factories/oauth_applications.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_application +# +# id: :integer +# name: :string +# uid: :string +# secret: :string +# redirect_uri: :text +# scopes: :string +# confidential: :boolean +# created_at: :datetime +# updated_at: :datetime + +FactoryBot.define do + factory :oauth_application, class: 'doorkeeper/application' do + name { Faker::Lorem.unique.word } + uid { SecureRandom.uuid } + secret { SecureRandom.uuid } + redirect_uri { "https://#{Faker::Internet.unique.domain_name}/callback" } + scopes { 'read' } + end +end diff --git a/spec/factories/research_domains.rb b/spec/factories/research_domains.rb index 7d4017f924..23049903be 100644 --- a/spec/factories/research_domains.rb +++ b/spec/factories/research_domains.rb @@ -23,6 +23,5 @@ factory :research_domain do identifier { SecureRandom.uuid } label { Faker::Lorem.unique.word } - uri { Faker::Internet.url } end end diff --git a/spec/requests/api/v2/plans_controller_spec.rb b/spec/requests/api/v2/plans_controller_spec.rb new file mode 100644 index 0000000000..bd09f7783b --- /dev/null +++ b/spec/requests/api/v2/plans_controller_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::PlansController do + include ApiHelper + include Mocks::ApiV2JsonSamples + include Webmocks + include IdentifierHelper + + context 'OAuth (authorization_code grant type) — on behalf of a user' do + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_plans_json_response + get(api_v2_plans_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/plans/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/plans (index)' do + context 'an invalid API token is included' do + it 'returns a 401 and the expected Oauth 2.0 headers' do + # Swap actual token with a random string + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_plans_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + let(:json) { fetch_plans_json_response } + it 'returns a 200 and the expected response body' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Server and source are present and sensible + expect(json[:server]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/plans') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Client is included + expect(json[:client]).to eq(@client.name) + end + + it 'returns an empty array if no plans are available' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + end + + it 'returns the expected plans' do + # See `app/policies/api/v2/plans_policy.rb for plans included/excluded via `GET api/v2/plans` + + # Create the included plans + included_plans = [create(:plan, org: @user.org), create(:plan)] + included_plans[0].add_user!(@user.id, :creator) + # Add multiple roles for testing (ensure duplicate plans will not returned) + included_plans[1].add_user!(@user.id, :editor) + included_plans[1].add_user!(@user.id, :commenter) + + # Created the excluded plans + create(:plan, :creator, org: @user.org) + inactive_plan = create(:plan, :creator) + inactive_plan.add_user!(@user.id, :editor) + Role.where(plan_id: inactive_plan.id, user_id: @user.id).update!(active: false) + + expect(json[:items].length).to be(included_plans.length) + + # Api::V2::PlanPresenter.identifier uses api_v2_plan_url(@plan) to set the "identifier". + # That url is constructed using `request.host` / "www.example.com" + # api_v2_plan_url(@plan) within this test will construct the url via + # default_url_options[:host] / "example.org" + # Because the urls are misaligned, we will only compare the paths here. + # TODO: Consider aligning default_url_options[:host] (in test.rb) with `request.host` + returned_identifiers = json[:items].map { |item| item[:dmp][:dmp_id][:identifier] } + returned_paths = returned_identifiers.map { |url| URI(url).path } + expected_paths = included_plans.map { |plan| api_v2_plan_path(plan) } + expect(returned_paths).to eq(expected_paths) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + + create_list(:plan, 11, :publicly_visible) do |plan| + plan.add_user!(@user.id, :commenter) + end + json = fetch_plans_json_response + + test_paging(json: json, headers: @headers) + + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end + end +end diff --git a/spec/requests/api/v2/templates_controller_spec.rb b/spec/requests/api/v2/templates_controller_spec.rb new file mode 100644 index 0000000000..f5a25ac110 --- /dev/null +++ b/spec/requests/api/v2/templates_controller_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::TemplatesController do + include ApiHelper + + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_templates_json_response + get(api_v2_templates_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/templates (index)' do + context 'an invalid API token is included' do + it 'returns 401 if the token is invalid' do + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + it 'returns a 200 and the expected response body' do + json = fetch_templates_json_response + + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Server and source are present and sensible + expect(json[:server]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/templates') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Client is included + expect(json[:client]).to eq(@client.name) + end + + it 'returns an empty array if no templates are available' do + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('200') + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + + json = JSON.parse(response.body).with_indifferent_access + expect(json[:items].empty?).to be(true) + expect(json[:errors].nil?).to be(true) + end + + it 'returns the expected templates' do + # See `app/policies/api/v2/templates_policy.rb for templates included/excluded via `GET api/v2/templates` + + # All included templates must be published and are either: + # - 1) organisationally_visible and template.org_id == user.org_id + # - 2) publicly_visible and customization of == nil + + public_template = create(:template, :publicly_visible, published: true) + + included_templates = [ + public_template, + create(:template, :organisationally_visible, published: true, org: @user.org) + ] + + # excluded_templates + # unpublished template + create(:template, :publicly_visible, published: false, org: @user.org) + # organisationally_visible and template.org_id != user.org_id + create(:template, :organisationally_visible, published: true) + # publicly_visible and customization of != nil + create(:template, :publicly_visible, published: true, customization_of: public_template.family_id) + + json = fetch_templates_json_response + + expect(json[:items].length).to be(2) + template_ids = json[:items].map { |item| item[:dmp_template][:template_id][:identifier] } + expect(template_ids).to match_array(included_templates.map { |t| t.id.to_s }) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + create_list(:template, 11, visibility: 1, published: true) + get(api_v2_templates_path, headers: @headers) + + test_paging(json: JSON.parse(response.body), headers: @headers) + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end +end diff --git a/spec/support/helpers/api.rb b/spec/support/helpers/api.rb index c87931c12e..2a2398dfe7 100644 --- a/spec/support/helpers/api.rb +++ b/spec/support/helpers/api.rb @@ -18,4 +18,44 @@ def mock_authorization_for_user(user: nil) Api::V1::BaseApiController.any_instance.stubs(:authorize_request).returns(true) Api::V1::BaseApiController.any_instance.stubs(:client).returns(user) end + + # API V2+ - Oauth authorization_code grant flow (on behalf of a user) + def mock_authorization_code_token(oauth_application: create(:oauth_application), user: create(:user), + scopes: 'read') + create(:oauth_access_grant, application_id: oauth_application.id, resource_owner_id: user.id, scopes: scopes) + create(:oauth_access_token, application: oauth_application, resource_owner_id: user.id, scopes: scopes) + end + + # Tests the standard pagination functionality + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def test_paging(json: {}, headers: {}) + json = json.with_indifferent_access + original = json[:items].first + if json[:next].present? + # Move to the next page + get(json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:prev].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + # Move back to previous page + get(next_json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:items].first).to eql(original) + elsif json[:prev].present? + get(json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:next].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + get(prev_json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:items].first).to eql(original) + else + raise StandardError, 'Expected to test API pagination but there are not enough items!' + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end diff --git a/spec/support/mocks/api_v2_json_samples.rb b/spec/support/mocks/api_v2_json_samples.rb new file mode 100644 index 0000000000..aaec320811 --- /dev/null +++ b/spec/support/mocks/api_v2_json_samples.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +# Mock JSON submissions +module Mocks + # Disabling rubocop checks here since its basically just large hashes and + # would be difficult to read if broken up into multiple smaller functions. + # One option might be to store them as .json files and then just load them here + # but we would lose the use of Faker + + # rubocop:disable Metrics/ModuleLength, Metrics/MethodLength + module ApiV2JsonSamples + ROLES = %w[Investigation Project_administration Data_curation].freeze + + def mock_identifier_schemes + create(:identifier_scheme, name: 'ror') + create(:identifier_scheme, name: 'fundref') + create(:identifier_scheme, name: 'orcid') + create(:identifier_scheme, name: 'grant') + end + + def minimal_update_json + { + total_items: 1, + items: [ + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + dmp_id: { + type: 'doi', + identifier: SecureRandom.uuid + } + } + } + ] + }.to_json + end + + def minimal_create_json + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + extension: [ + "#{ApplicationService.application_name.split('-').first}": { + template: { + id: Template.last.id, + title: Faker::Lorem.sentence + } + } + ] + } + }.to_json + end + + # rubocop:disable Metrics/AbcSize + def complete_create_json(client: nil) + template = create(:template, :published, :publicly_visible) + lang = Language.all.pluck(:abbreviation).sample || 'en-UK' + ror_scheme = IdentifierScheme.find_or_create_by(name: 'ror') + fundref_scheme = IdentifierScheme.find_or_create_by(name: 'fundref') + ror = create(:identifier, identifiable: create(:org), identifier_scheme: ror_scheme) + fundref = create(:identifier, identifiable: create(:org), identifier_scheme: fundref_scheme) + + contact = { + name: [ + Faker::TvShows::Simpsons.character.split.first, + Faker::TvShows::Simpsons.character.split.last + ].join(' '), + email: Faker::Internet.email, + id: SecureRandom.uuid + } + + { + dmp: { + created: 3.months.ago.to_formatted_s(:iso8601), + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + language: Api::V1::LanguagePresenter.three_char_code(lang: lang), + ethical_issues_exist: %w[yes no unknown].sample, + ethical_issues_description: Faker::Lorem.paragraph, + ethical_issues_report: Faker::Internet.url, + dmp_id: { + type: client.present? ? client.name.downcase : 'other', + identifier: SecureRandom.uuid + }, + contact: { + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + region: Faker::Space.planet, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contact_id: { + type: 'orcid', + identifier: contact[:id] + } + }, + contributor: [{ + role: [ + 'http://credit.niso.org/contributor-roles/project-administration', + 'http://credit.niso.org/contributor-roles/investigation', + 'other' + ], + name: Faker::Movies::StarWars.character, + mbox: Faker::Internet.email, + affiliation: { + name: Faker::Movies::StarWars.planet, + abbreviation: Faker::Lorem.word.upcase, + affiliation_id: { + type: 'ror', + identifier: SecureRandom.uuid + } + }, + contributor_id: { + type: 'orcid', + identifier: SecureRandom.uuid + } + }, { + role: [ + 'http://credit.niso.org/contributor-roles/investigation' + ], + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contributor_id: { + type: 'orcid', + identifier: contact[:id] + } + }], + project: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + start: 3.months.from_now.to_formatted_s(:iso8601), + end: 2.years.from_now.to_formatted_s(:iso8601), + funding: [{ + name: fundref.identifiable.name, + funder_id: { + type: 'fundref', + identifier: fundref.value + }, + grant_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + dmproadmap_funding_opportunity_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + funding_status: %w[planned rejected granted].sample + }] + }], + dataset: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + personal_data: %w[yes no unknown].sample, + sensitive_data: %w[yes no unknown].sample, + issued: 6.months.from_now.to_formatted_s(:iso8601), + dataset_id: { + type: 'url', + identifier: Faker::Internet.url + }, + distribution: [{ + title: Faker::Lorem.sentence, + byte_size: Faker::Number.number(digits: 6), + data_access: %w[open embargoed restricted closed].sample, + host: { + title: Faker::Company.name, + description: Faker::Lorem.paragraph, + url: Faker::Internet.url, + dmproadmap_host_id: { + type: 'url', + identifier: Faker::Internet.url + } + }, + license: [ + { + license_ref: 'http://spdx.org/licenses/CC0-1.0.json', + start_date: 6.months.from_now.to_formatted_s(:iso8601) + } + ] + }] + }], + dmproadmap_template: { id: template.family_id, title: template.title } + } + }.to_json + end + # rubocop:enable Metrics/AbcSize + end + # rubocop:enable Metrics/ModuleLength, Metrics/MethodLength +end diff --git a/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb new file mode 100644 index 0000000000..2758d51d22 --- /dev/null +++ b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/_standard_response.json.jbuilder' do + before do + @application = Faker::Lorem.word + # @caller = Faker::Lorem.word + @url = Faker::Internet.url + @code = 200 + + assign :application, @application + # assign :caller, @caller + + @response = OpenStruct.new(status: @code) + @request = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'standard response items - Also the same as: GET /heartbeat' do + before do + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :code' do + expect(@json[:code]).to eql(@code) + end + + it 'includes the :message' do + expect(@json[:message]).to eql(Rack::Utils::HTTP_STATUS_CODES[@code]) + end + + it 'includes the :time' do + expect(@json[:time].present?).to be(true) + end + + it ':time is in UTC format' do + expect(Date.parse(@json[:time]).is_a?(Date)).to be(true) + end + + # it 'includes the :caller' do + # expect(@json[:caller]).to eql(@caller) + # end + + it 'includes the :source' do + expect(@json[:source].include?(@url)).to be(true) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(0) + end + end + + context 'responses with pagination' do + describe 'On the 1st page and there is only one page' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 3 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(3) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:prev].present?).to be(false) + end + end + + describe 'On the 1st page and there multiple pages' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 4 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(4) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the 2nd page and there more than 2 pages' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 7 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(7) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the last page' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 5 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(5) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(false) + end + end + end +end diff --git a/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c2652decaf --- /dev/null +++ b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/contributors/_show.json.jbuilder' do + before do + @plan = create(:plan) + scheme = create(:identifier_scheme, name: 'orcid') + @contact = create(:contributor, org: create(:org), plan: @plan, roles_count: 0, + data_curation: true) + @ident = create(:identifier, identifiable: @contact, value: Faker::Lorem.word, + identifier_scheme: scheme) + @contact.reload + end + + describe 'includes all of the Contributor attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'includes the :role' do + expect(@json[:role].first.ends_with?('data-curation')).to be(true) + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contributor_id' do + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contributor_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + end + + describe 'includes all of the Contact attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact, + is_contact: true } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'does NOT include the :role' do + expect(@json[:role]).to be_nil + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contact_id' do + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contact_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..fc1b7d4e7b --- /dev/null +++ b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/datasets/_show.json.jbuilder' do + context ':output is a ResearchOutput' do + describe 'includes all of the dataset attributes' do + before do + @research_output = create(:research_output, plan: create(:plan)) + @presenter = Api::V2::ResearchOutputPresenter.new(output: @research_output) + end + + describe 'base :dataset attributes' do + before do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql(@research_output.output_type) + end + + it 'includes :title' do + expect(@json[:title]).to eql(@research_output.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@research_output.description) + end + + it 'includes :personal_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.personal_data) + expect(@json[:personal_data]).to eql(val) + end + + it 'includes :sensitive_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.sensitive_data) + expect(@json[:sensitive_data]).to eql(val) + end + + it 'includes :issued' do + expect(@json[:issued]).to eql(@research_output.release_date.to_formatted_s(:iso8601)) + end + + it 'includes :preservation_statement' do + expect(@json[:preservation_statement]).to eql(@presenter.preservation_statement) + end + + it 'includes :security_and_privacy' do + expect(@json[:security_and_privacy]).to eql(@presenter.security_and_privacy) + end + + it 'includes :data_quality_assurance' do + expect(@json[:data_quality_assurance]).to eql(@presenter.data_quality_assurance) + end + end + + describe ':distribution' do + before do + @repo = @research_output.repositories.first + @license = create(:license) + @research_output.license_id = @license.id + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :distributions' do + expect(@json[:distribution].any?).to be(true) + end + + it 'includes :title' do + expected = "Anticipated distribution for #{@research_output.title}" + expect(@json[:distribution].first[:title]).to eql(expected) + end + + it 'includes :byte_size' do + expect(@json[:distribution].first[:byte_size]).to eql(@research_output.byte_size) + end + + it 'includes :data_access' do + expect(@json[:distribution].first[:data_access]).to eql(@research_output.access) + end + + it 'includes host[:title]' do + expect(@json[:distribution].first[:host][:title]).to eql(@repo.name) + end + + it 'includes host[:description]' do + expect(@json[:distribution].first[:host][:description]).to eql(@repo.description) + end + + it 'includes host[:url]' do + expect(@json[:distribution].first[:host][:url]).to eql(@repo.homepage) + end + + it 'includes host[:dmproadmap_host_id]' do + result = @json[:distribution].first[:host][:dmproadmap_host_id][:identifier] + expect(result).to eql(@repo.uri) + end + + it 'includes license[:license_ref]' do + expect(@json[:distribution].first[:license].first[:license_ref]).to eql(@license.uri) + end + + it 'includes license[:start_date]' do + expected = @research_output.release_date.to_formatted_s(:iso8601) + expect(@json[:distribution].first[:license].first[:start_date]).to eql(expected) + end + end + + describe ':metadata' do + before do + @standard = create(:metadata_standard) + @research_output.metadata_standards << @standard + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :metadata' do + expect(@json[:metadata].any?).to be(true) + end + + it 'includes :description' do + uri = @standard.uri + metadata = @json[:metadata].select { |ms| ms[:metadata_standard_id][:identifier] == uri } + expected = "#{@standard.title} - #{@standard.description}" + expect(metadata.first[:description].start_with?(expected)).to be(true) + expect(metadata.first[:metadata_standard_id].present?).to be(true) + expect(metadata.first[:metadata_standard_id][:type]).to eql('url') + expect(metadata.first[:metadata_standard_id][:identifier]).to eql(uri) + end + end + + describe ':technical_resources' do + it 'is always an empty array because this has not been implemented' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:technical_resource].any?).to be(false) + end + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + research_domain = create(:research_domain) + @research_output.plan.research_domain_id = research_domain.id + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(research_domain.label)) + expect(@json[:keyword].include?("#{research_domain.identifier} - #{research_domain.label}")) + end + + it 'is not included if no ResearchDomain is defined' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].present?).to be(false) + end + end + end + end + + context ':output is a Plan' do + describe 'includes all of the dataset attributes' do + before do + @plan = create(:plan) + @research_domain = create(:research_domain) + @plan.research_domain_id = @research_domain.id + + render partial: 'api/v2/datasets/show', locals: { output: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql('dataset') + end + + it 'includes :title' do + expect(@json[:title]).to eql('Generic dataset') + end + + it 'includes :description' do + expect(@json[:description]).to eql('No individual datasets have been defined for this DMP.') + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(@research_domain.label)) + expect(@json[:keyword].include?("#{@research_domain.identifier} - #{@research_domain.label}")) + end + end + end + end +end diff --git a/spec/views/api/v2/error.json.jbuilder_spec.rb b/spec/views/api/v2/error.json.jbuilder_spec.rb new file mode 100644 index 0000000000..ca2337ff6d --- /dev/null +++ b/spec/views/api/v2/error.json.jbuilder_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/error.json.jbuilder' do + before do + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + @errors = [Faker::Lorem.sentence, Faker::Lorem.sentence] + + assign :payload, { message: @errors } + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + + render template: 'api/v2/error', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'error responses from controllers' do + it 'renders the standard_response partial' do + expect(response).to render_template(partial: 'api/v2/_standard_response') + end + + it ':errors contains an array of error messages' do + expect(@json[:message]).to eql(@errors) + end + end +end diff --git a/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb new file mode 100644 index 0000000000..1808117b19 --- /dev/null +++ b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/heartbeat.json.jbuilder' do + before do + render template: 'api/v2/heartbeat', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items]).to eql([]) + end +end diff --git a/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..602169fd75 --- /dev/null +++ b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/identifiers/_show.json.jbuilder' do + before do + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, value: Faker::Lorem.word, + identifier_scheme: @scheme) + render partial: 'api/v2/identifiers/show', locals: { identifier: @identifier } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the identifier attributes' do + it 'includes :type' do + expect(@json[:type]).to eql(@identifier.identifier_format) + end + + it 'includes :identifier' do + expect(@json[:identifier]).to eql(@identifier.value) + end + end +end diff --git a/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5480ae0ee1 --- /dev/null +++ b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/orgs/_show.json.jbuilder' do + before do + scheme = create(:identifier_scheme, name: 'ror') + @org = create(:org) + @ident = create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + render partial: 'api/v2/orgs/show', locals: { org: @org } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the org attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@org.name) + end + + it 'includes :abbreviation' do + expect(@json[:abbreviation]).to eql(@org.abbreviation) + end + + it 'includes :region' do + expect(@json[:region]).to eql(@org.region.abbreviation) + end + + it 'includes :affiliation_id' do + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + + it 'uses the ROR over the FundRef :affiliation_id' do + scheme = create(:identifier_scheme, name: 'fundref') + create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb new file mode 100644 index 0000000000..712e9300e9 --- /dev/null +++ b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_cost.json.jbuilder' do + before do + # TODO: Implement this once the Currency question and Cost theme are in place + # and the PlanPresenter is extracting the info + @cost = { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + currency_code: Faker::Currency.code, + value: Faker::Number.decimal(l_digits: 2) + }.with_indifferent_access + + render partial: 'api/v2/plans/cost', locals: { cost: @cost } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the cost attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@cost[:title]) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@cost[:description]) + end + + it 'includes :currency_code' do + expect(@json[:currency_code]).to eql(@cost[:currency_code]) + end + + it 'includes :value' do + expect(@json[:value]).to eql(@cost[:value]) + end + end +end diff --git a/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb new file mode 100644 index 0000000000..e764e142b3 --- /dev/null +++ b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_funding.json.jbuilder' do + before do + @funder = create(:org, :funder) + create(:identifier, identifiable: @funder, + identifier_scheme: create(:identifier_scheme, name: 'fundref')) + @funder.reload + @plan = create(:plan, funder: @funder, org: create(:org), identifier: SecureRandom.uuid) + create(:identifier, identifiable: @plan.org, + identifier_scheme: create(:identifier_scheme, name: 'ror')) + @grant = create(:identifier, identifiable: @plan) + @plan.update(grant_id: @grant.id, funding_status: 'funded') + @plan.reload + + render partial: 'api/v2/plans/funding', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the funding attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@funder.name) + end + + it 'includes :funding_status' do + expected = Api::V1::FundingPresenter.status(plan: @plan) + expect(@json[:funding_status]).to eql(expected) + end + + it 'includes :funder_ids' do + id = @funder.identifiers.first + expect(@json[:funder_id][:type]).to eql(id.identifier_format) + expect(@json[:funder_id][:identifier]).to eql(id.value) + end + + it 'includes :dmproadmap_funding_opportunity_identifier' do + identifier = @plan.identifier + expect(@json[:dmproadmap_funding_opportunity_id][:type]).to eql('other') + expect(@json[:dmproadmap_funding_opportunity_id][:identifier]).to eql(identifier) + end + + it 'includes :grant_ids' do + expect(@json[:grant_id][:type]).to eql(@grant.identifier_format) + expect(@json[:grant_id][:identifier]).to eql(@grant.value) + end + + it 'includes :dmproadmap_funded_affiliations' do + org = @plan.org + expect(@json[:dmproadmap_funded_affiliations].any?).to be(true) + affil = @json[:dmproadmap_funded_affiliations].last + expect(affil[:name]).to eql(org.name) + expect(affil[:affiliation_id][:type]).to eql(org.identifiers.last.identifier_format) + expect(affil[:affiliation_id][:identifier]).to eql(org.identifiers.last.value) + end + end +end diff --git a/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb new file mode 100644 index 0000000000..53af088379 --- /dev/null +++ b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_project.json.jbuilder' do + before do + @plan = build(:plan, funder: build(:org, :funder)) + render partial: 'api/v2/plans/project', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the project attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes :start' do + expect(@json[:start]).to eql(@plan.start_date.to_formatted_s(:iso8601)) + end + + it 'includes :end' do + expect(@json[:end]).to eql(@plan.end_date.to_formatted_s(:iso8601)) + end + + it 'includes the :funder' do + expect(@json[:funding].length).to be(1) + end + end +end diff --git a/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..989a2f638b --- /dev/null +++ b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_show.json.jbuilder' do + before do + Rails.configuration.x.madmp.enable_dmp_id_registration = true + + @plan = create(:plan) + @data_contact = create(:contributor, data_curation: true, plan: @plan) + @pi = create(:contributor, investigation: true, plan: @plan) + @plan.contributors = [@data_contact, @pi] + create(:identifier, identifiable: @plan) + + # Create an Api Client and connect it to the Plan + @client = create(:api_client) + scheme = create(:identifier_scheme, :for_plans, name: @client.name.downcase) + @client_identifier = create(:identifier, identifier_scheme: scheme, identifiable: @plan) + + @plan.save + @plan.reload + @presenter = Api::V2::PlanPresenter.new(plan: @plan) + end + + describe 'includes all of the DMP attributes' do + before do + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes the :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes the :language' do + expected = Api::V1::LanguagePresenter.three_char_code( + lang: LocaleService.default_locale + ) + expect(@json[:language]).to eql(expected) + end + + it 'includes the :created' do + expect(@json[:created]).to eql(@plan.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@json[:modified]).to eql(@plan.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes :ethical_issues' do + expected = Api::V1::ConversionService.boolean_to_yes_no_unknown(@plan.ethical_issues) + expect(@json[:ethical_issues_exist]).to eql(expected) + end + + it 'includes :ethical_issues_description' do + expect(@json[:ethical_issues_description]).to eql(@plan.ethical_issues_description) + end + + it 'includes :ethical_issues_report' do + expect(@json[:ethical_issues_report]).to eql(@plan.ethical_issues_report) + end + + it 'returns the URL of the plan as the :dmp_id if no DMP ID is defined' do + expected = Rails.application.routes.url_helpers.api_v2_plan_url(@plan) + expect(@json[:dmp_id][:type]).to eql('url') + expect(@json[:dmp_id][:identifier]).to eql(expected) + end + + it 'includes the :contact' do + expect(@json[:contact][:mbox]).to eql(@data_contact.email) + end + + it 'includes the :contributors' do + emails = @json[:contributor].pluck(:mbox) + expect(emails.include?(@pi.email)).to be(true) + end + + # TODO: make sure this is working once the new Cost theme and Currency + # question type have been implemented + it 'includes the :cost' do + expect(@json[:cost]).to be_nil + end + + it 'includes the :project' do + expect(@json[:project].length).to be(1) + end + + it 'includes the :dataset' do + expect(@json[:dataset].length).to be(1) + end + end + + describe 'when the system mints DMP IDs', skip: 'DmpIdService not implemented' do + before do + scheme = create(:identifier_scheme) + DmpIdService.expects(:identifier_scheme).at_least(1).returns(scheme) + @doi = create(:identifier, value: '10.9999/123abc.zy/x23', identifiable: @plan, + identifier_scheme: scheme) + @plan.reload + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'returns the DMP ID for the :dmp_id if one is present' do + expect(@json[:dmp_id][:type]).to eql('doi') + expect(@json[:dmp_id][:identifier]).to eql(@doi.value) + end + end +end diff --git a/spec/views/api/v2/plans/index.json.jbuilder_spec.rb b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5d47770c52 --- /dev/null +++ b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/index.json.jbuilder' do + before do + @plan = create(:plan) + + @client = create(:api_client) + @items = [@plan] + @total_items = 1 + + render template: 'api/v2/plans/index' + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items].length).to be(1) + expect(@json[:items].first[:dmp][:title]).to eql(@plan.title) + end +end diff --git a/spec/views/api/v2/templates/index.json.jbuilder_spec.rb b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..99b35375ec --- /dev/null +++ b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/templates/index.json.jbuilder' do + before do + @application = Faker::Lorem.word + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + + @template1 = create(:template, :published, org: create(:org), phases: 1) + @template2 = create(:template, :published) + + assign :server, @application + assign :items, [@template1, @template2] + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'includes all of the Template attributes' do + before do + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + + @template = @json[:items].first[:dmp_template] + end + + it 'includes both templates' do + expect(@json[:items].length).to be(2) + end + + it 'includes the :title' do + expect(@template[:title]).to eql(@template1.title) + end + + it 'includes the :description' do + expect(@template[:description]).to eql(@template1.description) + end + + it 'includes the :version' do + expect(@template[:version]).to eql(@template1.version) + end + + it 'includes the :created' do + expect(@template[:created]).to eql(@template1.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@template[:modified]).to eql(@template1.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes the :affiliation' do + expect(@template[:affiliation][:name]).to eql(@template1.org.name) + end + + it 'includes the :template_ids' do + expect(@template[:template_id][:identifier]).to eql(@template1.id.to_s) + expect(@template[:template_id][:type]).to eql('other') + end + + # The show_url parameter is either false or not included + it 'includes the :phases' do + expect(@json[:items].first[:dmp_template][:phases]).to be_nil + end + end + + # The show_url parameter is true + describe 'when the show_phases url parameter is true' do + before do + @show_phases = true + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req, show_phases: @show_phases } + @json = JSON.parse(rendered).with_indifferent_access + end + end +end From 8e9201781ddaf9dc400e71df74bba89f651805d6 Mon Sep 17 00:00:00 2001 From: aaronskiba Date: Tue, 16 Dec 2025 13:05:14 -0700 Subject: [PATCH 2/2] Fix bugs uncovered by v2 API test coverage These changes resolve the following issues uncovered via the API v2 tests: - app/views/api/v2/_standard_response.json.jbuilder - Failure rendering json.client when @client is nil (undefined method `name` for nil) - app/views/api/v2/datasets/_show.json.jbuilder - Failure rendering output.doi_url when output is present (undefined method `doi_url`) - app/views/api/v2/templates/index.json.jbuilder - Failure in mapping template_ids when some nested keys are nil (undefined method `[]` for nil) - config/routes.rb - Spec failures due to undefined route helpers in test context (e.g., `api_v2_templates_path`) --- app/views/api/v2/_standard_response.json.jbuilder | 2 +- app/views/api/v2/datasets/_show.json.jbuilder | 1 - app/views/api/v2/templates/index.json.jbuilder | 2 +- config/routes.rb | 1 + 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/api/v2/_standard_response.json.jbuilder b/app/views/api/v2/_standard_response.json.jbuilder index c979fec01e..cc4f8b05a7 100644 --- a/app/views/api/v2/_standard_response.json.jbuilder +++ b/app/views/api/v2/_standard_response.json.jbuilder @@ -15,7 +15,7 @@ json.ignore_nil! json.server @server json.source "#{request.method} #{request.path}" json.time Time.now.to_formatted_s(:iso8601) -json.client @client.name +json.client @client&.name json.code response.status json.message Rack::Utils::HTTP_STATUS_CODES[response.status] diff --git a/app/views/api/v2/datasets/_show.json.jbuilder b/app/views/api/v2/datasets/_show.json.jbuilder index 1581dff785..6e856019cd 100644 --- a/app/views/api/v2/datasets/_show.json.jbuilder +++ b/app/views/api/v2/datasets/_show.json.jbuilder @@ -7,7 +7,6 @@ if output.is_a?(ResearchOutput) json.type output.output_type json.title output.title - json.doi_url output.doi_url json.description output.description json.personal_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) json.sensitive_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) diff --git a/app/views/api/v2/templates/index.json.jbuilder b/app/views/api/v2/templates/index.json.jbuilder index cdb35f54fb..8f5860f2c2 100644 --- a/app/views/api/v2/templates/index.json.jbuilder +++ b/app/views/api/v2/templates/index.json.jbuilder @@ -17,7 +17,7 @@ json.items @items do |template| end json.template_id do - identifier = Api::V2::ConversionService.to_identifier(context: @application, + identifier = Api::V2::ConversionService.to_identifier(context: @server, value: template.id) json.partial! 'api/v2/identifiers/show', identifier: identifier end diff --git a/config/routes.rb b/config/routes.rb index 152c4a327d..274c494d1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -210,6 +210,7 @@ get :me, controller: :base_api resources :plans, only: %i[index show] + resources :templates, only: :index end end