Commit 299f973a authored by David Fernandez's avatar David Fernandez Committed by Adam Hegyi

Add the nuget search service

See https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
Add the relevant service and presenter
parent 5cd90761
---
title: Add specialized index to packages_packages database table
merge_request: 24182
author:
type: other
# frozen_string_literal: true
class AddNugetIndexToPackagesPackages < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_packages_project_id_name_partial_for_nuget'
disable_ddl_transaction!
def up
add_concurrent_index :packages_packages, [:project_id, :name], name: INDEX_NAME, where: "name <> 'NuGet.Temporary.Package' AND version is not null AND package_type = 4"
end
def down
remove_concurrent_index_by_name :packages_packages, INDEX_NAME
end
end
......@@ -3012,6 +3012,7 @@ ActiveRecord::Schema.define(version: 2020_02_13_204737) do
t.index ["name"], name: "index_packages_packages_on_name_trigram", opclass: :gin_trgm_ops, using: :gin
t.index ["project_id", "created_at"], name: "index_packages_packages_on_project_id_and_created_at"
t.index ["project_id", "name", "version", "package_type"], name: "idx_packages_packages_on_project_id_name_version_package_type"
t.index ["project_id", "name"], name: "index_packages_project_id_name_partial_for_nuget", where: "(((name)::text <> 'NuGet.Temporary.Package'::text) AND (version IS NOT NULL) AND (package_type = 4))"
t.index ["project_id", "package_type"], name: "index_packages_packages_on_project_id_and_package_type"
t.index ["project_id", "version"], name: "index_packages_packages_on_project_id_and_version"
end
......
# frozen_string_literal: true
class Packages::Package < ApplicationRecord
include Sortable
include Gitlab::SQL::Pattern
belongs_to :project
# package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics
......@@ -33,17 +34,20 @@ class Packages::Package < ApplicationRecord
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
scope :with_version, ->(version) { where(version: version) }
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
scope :with_package_type, ->(package_type) { where(package_type: package_type) }
scope :with_conan_channel, ->(package_channel) do
joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel })
end
scope :with_conan_username, ->(package_username) do
joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username })
end
scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
scope :has_version, -> { where.not(version: nil) }
scope :processed, -> do
where.not(package_type: :nuget).or(
......@@ -53,6 +57,7 @@ class Packages::Package < ApplicationRecord
scope :preload_files, -> { preload(:package_files) }
scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
scope :select_distinct_name, -> { select(:name).distinct }
# Sorting
scope :order_created, -> { reorder('created_at ASC') }
......
# frozen_string_literal: true
module Packages
module Nuget
class SearchResultsPresenter
include API::Helpers::Packages::Nuget::MetadataPresenterHelpers
include Gitlab::Utils::StrongMemoize
delegate :total_count, to: :@search
def initialize(search)
@search = search
@package_versions = {}
end
def data
strong_memoize(:data) do
@search.results.group_by(&:name).map do |package_name, packages|
{
type: 'Package',
authors: '',
name: package_name,
version: latest_version(packages),
versions: build_package_versions(packages),
summary: '',
total_downloads: 0,
verified: true
}
end
end
end
private
def build_package_versions(packages)
packages.map do |pkg|
{
json_url: json_url_for(pkg),
downloads: 0,
version: pkg.version
}
end
end
def latest_version(packages)
versions = packages.map(&:version).compact
VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort
end
end
end
end
# frozen_string_literal: true
module Packages
module Nuget
class SearchService < BaseService
include Gitlab::Utils::StrongMemoize
include ActiveRecord::ConnectionAdapters::Quoting
MAX_PER_PAGE = 30
MAX_VERSIONS_PER_PACKAGE = 10
DEFAULT_OPTIONS = {
include_prerelease_versions: true,
per_page: Kaminari.config.default_per_page,
padding: 0
}.freeze
def initialize(project, search_term, options = {})
@project = project
@search_term = search_term
@options = DEFAULT_OPTIONS.merge(options)
raise ArgumentError, 'negative per_page' if per_page.negative?
raise ArgumentError, 'negative padding' if padding.negative?
end
def execute
OpenStruct.new(
total_count: package_names.total_count,
results: search_packages
)
end
private
def search_packages
# custom query to get package names and versions as expected from the nuget search api
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24182#technical-notes
# and https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
subquery_name = :partition_subquery
column_names = Packages::Package.column_names.map do |cn|
"#{subquery_name}.#{quote_column_name(cn)}"
end
# rubocop: disable CodeReuse/ActiveRecord
Packages::Package.select(column_names.join(','))
.from(package_names_partition, subquery_name)
.where(
"#{subquery_name}.row_number <= :max_versions_count",
max_versions_count: MAX_VERSIONS_PER_PACKAGE
)
# rubocop: enable CodeReuse/ActiveRecord
end
def package_names_partition
table_name = quote_table_name(Packages::Package.table_name)
name_column = "#{table_name}.#{quote_column_name('name')}"
created_at_column = "#{table_name}.#{quote_column_name('created_at')}"
select_sql = "ROW_NUMBER() OVER (PARTITION BY #{name_column} ORDER BY #{created_at_column} DESC) AS row_number, #{table_name}.*"
@project.packages
.select(select_sql)
.nuget
.has_version
.without_nuget_temporary_name
.with_name(package_names)
end
def package_names
strong_memoize(:package_names) do
pkgs = @project.packages
.nuget
.has_version
.without_nuget_temporary_name
.order_name
.select_distinct_name
pkgs = pkgs.without_version_like('%-%') unless include_prerelease_versions?
pkgs = pkgs.search_by_name(@search_term) if @search_term.present?
pkgs.page(0) # we're using a padding
.per(per_page)
.padding(padding)
end
end
def include_prerelease_versions?
@options[:include_prerelease_versions]
end
def padding
@options[:padding]
end
def per_page
[@options[:per_page], MAX_PER_PAGE].min
end
end
end
end
......@@ -13,6 +13,7 @@ module API
AUTHENTICATE_REALM_HEADER = 'Www-Authenticate: Basic realm'
AUTHENTICATE_REALM_NAME = 'GitLab Nuget Package Registry'
POSITIVE_INTEGER_REGEX = %r{\A[1-9]\d*\z}.freeze
NON_NEGATIVE_INTEGER_REGEX = %r{\A0|[1-9]\d*\z}.freeze
PACKAGE_FILENAME = 'package.nupkg'
......@@ -205,6 +206,36 @@ module API
present_carrierwave_file!(package_file.file, supports_direct_download: false)
end
end
params do
requires :q, type: String, desc: 'The search term'
optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX
optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX
optional :prerelease, type: Boolean, desc: 'Include prerelease versions', default: true
end
namespace '/query' do
before do
authorize_read_package!(authorized_user_project)
end
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
desc 'The NuGet Search Service' do
detail 'This feature was introduced in GitLab 12.8'
end
get format: :json do
search_options = {
include_prerelease_versions: params[:prerelease],
per_page: params[:take],
padding: params[:skip]
}
search = Packages::Nuget::SearchService
.new(authorized_user_project, params[:q], search_options)
.execute
present ::Packages::Nuget::SearchResultsPresenter.new(search),
with: EE::API::Entities::Nuget::SearchResults
end
end
end
end
end
......
......@@ -917,6 +917,29 @@ module EE
class PackagesVersions < Grape::Entity
expose :versions
end
class SearchResultVersion < Grape::Entity
expose :json_url, as: :@id
expose :version
expose :downloads
end
class SearchResult < Grape::Entity
expose :type, as: :@type
expose :authors
expose :name, as: :id
expose :name, as: :title
expose :summary
expose :total_downloads, as: :totalDownloads
expose :verified
expose :version
expose :versions, using: EE::API::Entities::Nuget::SearchResultVersion
end
class SearchResults < Grape::Entity
expose :total_count, as: :totalHits
expose :data, using: EE::API::Entities::Nuget::SearchResult
end
end
class NpmPackage < Grape::Entity
......
{
"type": "object",
"required": ["totalHits", "data"],
"properties": {
"totalHits": { "type": "integer" },
"data": {
"type": "array",
"items": {
"type": "object",
"required": ["@type", "authors", "id", "summary", "title", "totalDownloads", "verified", "versions"],
"properties": {
"@type": { "const": "Package" },
"authors": { "const": "" },
"id": { "type": "string" },
"summary": { "const": "" },
"title": { "type": "string" },
"totalDownloads": { "const": 0 },
"verified": { "const": true },
"versions": {
"type": "array",
"items": {
"type": "object",
"required": ["@id", "version", "downloads"],
"properties": {
"@id": { "type": "string" },
"version": { "type": "string" },
"downloads": { "const": 0 }
}
}
}
}
}
}
}
}
......@@ -134,6 +134,16 @@ RSpec.describe Packages::Package, type: :model do
is_expected.to match_array([package2, package3])
end
end
describe '.without_version_like' do
let(:version_pattern) { '%.0.0%' }
subject { described_class.without_version_like(version_pattern) }
it 'includes packages without the version pattern' do
is_expected.to match_array([package2, package3])
end
end
end
context 'conan scopes' do
......@@ -160,6 +170,17 @@ RSpec.describe Packages::Package, type: :model do
end
end
describe '.without_nuget_temporary_name' do
let!(:package1) { create(:nuget_package) }
let!(:package2) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
subject { described_class.without_nuget_temporary_name }
it 'does not include nuget temporary packages' do
expect(subject).to eq([package1])
end
end
describe '.processed' do
let!(:package1) { create(:nuget_package) }
let!(:package2) { create(:npm_package) }
......@@ -167,12 +188,12 @@ RSpec.describe Packages::Package, type: :model do
subject { described_class.processed }
it { is_expected.to eq([package1, package2, package3]) }
it { is_expected.to match_array([package1, package2, package3]) }
context 'with temporary packages' do
let!(:package1) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
it { is_expected.to eq([package2, package3]) }
it { is_expected.to match_array([package2, package3]) }
end
end
......@@ -187,48 +208,73 @@ RSpec.describe Packages::Package, type: :model do
end
context 'with several packages' do
let_it_be(:package1) { create(:nuget_package, name: 'FooBarish') }
let_it_be(:package2) { create(:npm_package) }
let_it_be(:package1) { create(:nuget_package, name: 'FooBar') }
let_it_be(:package2) { create(:nuget_package, name: 'foobar') }
let_it_be(:package3) { create(:npm_package) }
let_it_be(:package4) { create(:npm_package) }
describe '.pluck_names' do
subject { described_class.pluck_names.sort }
subject { described_class.pluck_names }
it { is_expected.to match_array([package1, package2, package3].map(&:name).sort) }
it { is_expected.to match_array([package1, package2, package3, package4].map(&:name)) }
end
describe '.pluck_versions' do
subject { described_class.pluck_versions.sort }
subject { described_class.pluck_versions }
it { is_expected.to match_array([package1, package2, package3].map(&:version).sort) }
it { is_expected.to match_array([package1, package2, package3, package4].map(&:version)) }
end
describe '.with_name_like' do
subject { described_class.with_name_like(package_name) }
subject { described_class.with_name_like(name_term) }
context 'with downcase name' do
let(:package_name) { 'foobarish' }
let(:name_term) { 'foobar' }
it { is_expected.to match_array([package1]) }
it { is_expected.to match_array([package1, package2]) }
end
context 'with prefix wildcard' do
let(:package_name) { '%arish' }
let(:name_term) { '%ar' }
it { is_expected.to match_array([package1]) }
it { is_expected.to match_array([package1, package2]) }
end
context 'with suffix wildcard' do
let(:package_name) { 'foo%' }
let(:name_term) { 'foo%' }
it { is_expected.to match_array([package1]) }
it { is_expected.to match_array([package1, package2]) }
end
context 'with surrounding wildcards' do
let(:package_name) { '%ooba%' }
let(:name_term) { '%ooba%' }
it { is_expected.to match_array([package1]) }
it { is_expected.to match_array([package1, package2]) }
end
end
describe '.search_by_name' do
let(:query) { 'oba' }
subject { described_class.search_by_name(query) }
it { is_expected.to match_array([package1, package2]) }
end
end
describe '.select_distinct_name' do
let_it_be(:nuget_package) { create(:nuget_package) }
let_it_be(:nuget_packages) { create_list(:nuget_package, 3, name: nuget_package.name, project: nuget_package.project) }
let_it_be(:maven_package) { create(:maven_package) }
let_it_be(:maven_packages) { create_list(:maven_package, 3, name: maven_package.name, project: maven_package.project) }
subject { described_class.select_distinct_name }
it 'returns only distinct names' do
packages = subject
expect(packages.size).to eq(2)
expect(packages.pluck(:name)).to match_array([nuget_package.name, maven_package.name])
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::SearchResultsPresenter do
let_it_be(:project) { create(:project) }
let_it_be(:package_a) { create(:nuget_package, project: project, name: 'DummyPackageA') }
let_it_be(:packages_b) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageB') }
let_it_be(:packages_c) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageC') }
let_it_be(:search_results) { OpenStruct.new(total_count: 3, results: [package_a, packages_b, packages_c].flatten) }
let_it_be(:presenter) { described_class.new(search_results) }
let(:total_count) { presenter.total_count }
let(:data) { presenter.data }
describe '#total_count' do
it 'expects to have 3 total elements' do
expect(total_count).to eq(3)
end
end
describe '#data' do
it 'returns the proper data structure' do
expect(data.size).to eq 3
pkg_a, pkg_b, pkg_c = data
expect_package_result(pkg_a, package_a.name, [package_a.version])
expect_package_result(pkg_b, packages_b.first.name, packages_b.map(&:version))
expect_package_result(pkg_c, packages_c.first.name, packages_c.map(&:version))
end
def expect_package_result(package_json, name, versions)
expect(package_json[:type]).to eq 'Package'
expect(package_json[:authors]).to be_blank
expect(package_json[:name]).to eq(name)
expect(package_json[:summary]).to be_blank
expect(package_json[:total_downloads]).to eq 0
expect(package_json[:verified]).to be
expect(package_json[:version]).to eq VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort
versions.zip(package_json[:versions]).each do |version, version_json|
expect(version_json[:json_url]).to end_with("#{version}.json")
expect(version_json[:downloads]).to eq 0
expect(version_json[:version]).to eq version
end
end
end
end
......@@ -284,6 +284,10 @@ describe API::NugetPackages do
it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
end
it_behaves_like 'rejects nuget access with unknown project id'
it_behaves_like 'rejects nuget access with invalid project id'
end
end
......@@ -505,4 +509,82 @@ describe API::NugetPackages do
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
describe 'GET /api/v4/projects/:id/packages/nuget/query' do
let_it_be(:package_a) { create(:nuget_package, name: 'Dummy.PackageA', project: project) }
let_it_be(:packages_b) { create_list(:nuget_package, 5, name: 'Dummy.PackageB', project: project) }
let_it_be(:packages_c) { create_list(:nuget_package, 5, name: 'Dummy.PackageC', project: project) }
let_it_be(:package_d) { create(:nuget_package, name: 'Dummy.PackageD', version: '5.0.5-alpha', project: project) }
let_it_be(:package_e) { create(:nuget_package, name: 'Foo.BarE', project: project) }
let(:search_term) { 'uMmy' }
let(:take) { 26 }
let(:skip) { 0 }
let(:include_prereleases) { true }
let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } }
let(:url) { "/projects/#{project.id}/packages/nuget/query?#{query_parameters.to_query}" }
subject { get api(url) }
context 'with packages features enabled' do
before do
stub_licensed_features(packages: true)
end
context 'with feature flag enabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: true, thing: project })
end
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | true | 'process nuget search request' | :success
'PUBLIC' | :guest | true | true | 'process nuget search request' | :success
'PUBLIC' | :developer | true | false | 'process nuget search request' | :success
'PUBLIC' | :guest | true | false | 'process nuget search request' | :success
'PUBLIC' | :developer | false | true | 'process nuget search request' | :success
'PUBLIC' | :guest | false | true | 'process nuget search request' | :success
'PUBLIC' | :developer | false | false | 'process nuget search request' | :success
'PUBLIC' | :guest | false | false | 'process nuget search request' | :success
'PUBLIC' | :anonymous | false | true | 'process nuget search request' | :success
'PRIVATE' | :developer | true | true | 'process nuget search request' | :success
'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden
'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized
end
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) }
subject { get api(url), headers: headers }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
after do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
end
end
it_behaves_like 'rejects nuget access with unknown project id'
it_behaves_like 'rejects nuget access with invalid project id'
end
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::SearchService do
let_it_be(:project) { create(:project) }
let_it_be(:package_a) { create(:nuget_package, project: project, name: 'DummyPackageA') }
let_it_be(:packages_b) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageB') }
let_it_be(:packages_c) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageC') }
let_it_be(:package_d) { create(:nuget_package, project: project, name: 'FooBarD') }
let_it_be(:other_package_a) { create(:nuget_package, name: 'DummyPackageA') }
let_it_be(:other_package_a) { create(:nuget_package, name: 'DummyPackageB') }
let(:search_term) { 'ummy' }
let(:per_page) { 5 }
let(:padding) { 0 }
let(:include_prerelease_versions) { true }
let(:options) { { include_prerelease_versions: include_prerelease_versions, per_page: per_page, padding: padding } }
describe '#execute' do
subject { described_class.new(project, search_term, options).execute }
it { expect_search_results 3, package_a, packages_b, packages_c }
context 'with a smaller per page count' do
let(:per_page) { 2 }
it { expect_search_results 3, package_a, packages_b }
end
context 'with 0 per page count' do
let(:per_page) { 0 }
it { expect_search_results 3, [] }
end
context 'with a negative per page count' do
let(:per_page) { -1 }
it { expect { subject }.to raise_error(ArgumentError, 'negative per_page') }
end
context 'with a padding' do
let(:padding) { 2 }
it { expect_search_results 3, packages_c }
end
context 'with a too big padding' do
let(:padding) { 5 }
it { expect_search_results 3, [] }
end
context 'with a negative padding' do
let(:padding) { -1 }
it { expect { subject }.to raise_error(ArgumentError, 'negative padding') }
end
context 'with search term' do
let(:search_term) { 'umm' }
it { expect_search_results 3, package_a, packages_b, packages_c }
end
context 'with nil search term' do
let(:search_term) { nil }
it { expect_search_results 4, package_a, packages_b, packages_c, package_d }
end
context 'with empty search term' do
let(:search_term) { '' }
it { expect_search_results 4, package_a, packages_b, packages_c, package_d }
end
context 'with prefix search term' do
let(:search_term) { 'dummy' }
it { expect_search_results 3, package_a, packages_b, packages_c }
end
context 'with suffix search term' do
let(:search_term) { 'packagec' }
it { expect_search_results 1, packages_c }
end
context 'with pre release packages' do
let_it_be(:package_e) { create(:nuget_package, project: project, name: 'DummyPackageE', version: '3.2.1-alpha') }
context 'including them' do
it { expect_search_results 4, package_a, packages_b, packages_c, package_e }
end
context 'excluding them' do
let(:include_prerelease_versions) { false }
it { expect_search_results 3, package_a, packages_b, packages_c }
end
end
def expect_search_results(total_count, *results)
search = subject
expect(search.total_count).to eq total_count
expect(search.results).to match_array(Array.wrap(results).flatten)
end
end
end
......@@ -293,6 +293,60 @@ RSpec.shared_examples 'process nuget download content request' do |user_type, st
end
end
RSpec.shared_examples 'process nuget search request' do |user_type, status, add_member = true|
RSpec.shared_examples 'returns a valid json search response' do |status, total_hits, versions|
it_behaves_like 'returning response status', status
it 'returns a valid json response' do
subject
expect(response.media_type).to eq('application/json')
expect(json_response).to be_a(Hash)
expect(json_response).to match_schema('public_api/v4/packages/nuget/search', dir: 'ee')
expect(json_response['totalHits']).to eq total_hits
expect(json_response['data'].map { |e| e['versions'].size }).to match_array(versions)
end
end
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
end
it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1]
context 'with skip set to 2' do
let(:skip) { 2 }
it_behaves_like 'returns a valid json search response', status, 4, [5, 1]
end
context 'with take set to 2' do
let(:take) { 2 }
it_behaves_like 'returns a valid json search response', status, 4, [1, 5]
end
context 'without prereleases' do
let(:include_prereleases) { false }
it_behaves_like 'returns a valid json search response', status, 3, [1, 5, 5]
end
context 'with empty search term' do
let(:search_term) { '' }
it_behaves_like 'returns a valid json search response', status, 5, [1, 5, 5, 1, 1]
end
context 'with nil search term' do
let(:search_term) { nil }
it_behaves_like 'returns a valid json search response', status, 5, [1, 5, 5, 1, 1]
end
end
end
RSpec.shared_examples 'rejects nuget access with invalid project id' do
context 'with a project id with invalid integers' do
using RSpec::Parameterized::TableSyntax
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment