diff --git a/app/models/queries/work_packages.rb b/app/models/queries/work_packages.rb index d42dd21eee..15f3bd7952 100644 --- a/app/models/queries/work_packages.rb +++ b/app/models/queries/work_packages.rb @@ -78,6 +78,7 @@ module Queries::WorkPackages register.filter Query, filters_module::ManualSortFilter register.filter Query, filters_module::RelatableFilter register.filter Query, filters_module::MilestoneFilter + register.filter Query, filters_module::TypeaheadFilter columns_module = Queries::WorkPackages::Columns diff --git a/app/models/queries/work_packages/filter/typeahead_filter.rb b/app/models/queries/work_packages/filter/typeahead_filter.rb new file mode 100644 index 0000000000..f74d630137 --- /dev/null +++ b/app/models/queries/work_packages/filter/typeahead_filter.rb @@ -0,0 +1,63 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2021 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +class Queries::WorkPackages::Filter::TypeaheadFilter < + Queries::WorkPackages::Filter::WorkPackageFilter + def type + :search + end + + def where + parts = values.map(&:split).flatten + + parts.map do |part| + conditions = [subject_condition(part), + project_name_condition(part)] + + if (match = part.match(/^#?(\d+)$/)) + conditions << id_condition(match[1]) + end + + "(#{conditions.join(' OR ')})" + end.join(' AND ') + end + + def subject_condition(string) + Queries::Operators::Contains.sql_for_field([string], WorkPackage.table_name, 'subject') + end + + def project_name_condition(string) + Queries::Operators::Contains.sql_for_field([string], Project.table_name, 'name') + end + + def id_condition(string) + "#{WorkPackage.table_name}.id::varchar(20) LIKE '%#{string}%'" + end +end diff --git a/frontend/src/app/core/apiv3/api-v3.service.spec.ts b/frontend/src/app/core/apiv3/api-v3.service.spec.ts index 01733ca2c8..7158992f1b 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.spec.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.spec.ts @@ -62,14 +62,14 @@ describe('APIv3Service', () => { it('should provide a path to work package query on subject or ID ', () => { let params = { - filters: '[{"subjectOrId":{"operator":"**","values":["bogus"]}}]', + filters: '[{"typeahead":{"operator":"**","values":["bogus"]}}]', sortBy: '[["updatedAt","desc"]]', offset: '1', pageSize: '10', }; expect( - service.work_packages.filterBySubjectOrId('bogus').path, + service.work_packages.filterByTypeaheadOrId('bogus').path, ).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); params = { @@ -79,7 +79,7 @@ describe('APIv3Service', () => { pageSize: '10', }; expect( - service.work_packages.filterBySubjectOrId('1234', true).path, + service.work_packages.filterByTypeaheadOrId('1234', true).path, ).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); }); }); diff --git a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts index e4683b68ce..f00f8a7214 100644 --- a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts +++ b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts @@ -110,13 +110,13 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection => { + getAutocompleterData = (query:string|null):Observable => { // Return when the search string is empty if (query === null || query.length === 0) { this.isLoading = false; return of([]); } - // Remove prefix # from search - query = query.replace(/^#/, ''); - return from( this.workPackage.availableRelationCandidates.$link.$fetch({ query, diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts index 2a0a830299..9d561d52b1 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service.ts @@ -61,6 +61,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService< 'subjectOrId', 'subjectOr', 'manualSort', + 'typeahead' ]; /** Flag state to determine whether the filters are incomplete */ diff --git a/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts b/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts index 322a3e871d..5da55927d6 100644 --- a/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts +++ b/frontend/src/app/shared/components/fields/macros/attribute-model-loader.service.ts @@ -144,7 +144,7 @@ export class AttributeModelLoaderService { .apiV3Service .withOptionalProject(this.currentProject.id) .work_packages - .filterBySubjectOrId(id, false, { pageSize: '1' }) + .filterByTypeaheadOrId(id, false, { pageSize: '1' }) .get() .pipe( take(1), diff --git a/lib/api/v3/work_packages/available_relation_candidates_api.rb b/lib/api/v3/work_packages/available_relation_candidates_api.rb index a83a2089ab..6c31ca7411 100644 --- a/lib/api/v3/work_packages/available_relation_candidates_api.rb +++ b/lib/api/v3/work_packages/available_relation_candidates_api.rb @@ -41,7 +41,7 @@ module API end def string_filter - filter_param(:subject_or_id, '**', params[:query]) + filter_param(:typeahead, '**', params[:query]) end def type_filter @@ -55,10 +55,11 @@ module API resources :available_relation_candidates do params do - requires :query, type: String # either WP ID or part of its subject + requires :query, type: String # part of the WP ID and/or part of its subject and/or part of the projects name optional :type, type: String, default: ::Relation::TYPE_RELATES # relation type optional :pageSize, type: Integer, default: 10 end + get do service = WorkPackageCollectionFromQueryParamsService .new(current_user) diff --git a/spec/requests/api/v3/support/api_v3_collection_response.rb b/spec/requests/api/v3/support/api_v3_collection_response.rb index fbae2e2a8b..686410eed0 100644 --- a/spec/requests/api/v3/support/api_v3_collection_response.rb +++ b/spec/requests/api/v3/support/api_v3_collection_response.rb @@ -28,7 +28,7 @@ require 'spec_helper' -shared_examples_for 'API V3 collection response' do |total, count, type| +shared_examples_for 'API V3 collection response' do |total, count, element_type, collection_type = 'Collection'| subject { last_response.body } # If an array of elements is provided, those elements are expected # to be embedded in the _embedded/elements section in the order provided. @@ -56,13 +56,13 @@ shared_examples_for 'API V3 collection response' do |total, count, type| it 'returns a collection successfully' do aggregate_failures do expect(last_response.status).to eql(200) - expect(subject).to be_json_eql('Collection'.to_json).at_path('_type') + expect(subject).to be_json_eql(collection_type.to_json).at_path('_type') expect(subject).to be_json_eql(count_number.to_json).at_path('count') expect(subject).to be_json_eql(total_number.to_json).at_path('total') expect(subject).to have_json_size(count_number).at_path('_embedded/elements') - if type && count_number > 0 - expect(subject).to be_json_eql(type.to_json).at_path('_embedded/elements/0/_type') + if element_type && count_number > 0 + expect(subject).to be_json_eql(element_type.to_json).at_path('_embedded/elements/0/_type') end elements&.each_with_index do |element, index| diff --git a/spec/requests/api/v3/work_packages/create_resource_spec.rb b/spec/requests/api/v3/work_packages/create_resource_spec.rb new file mode 100644 index 0000000000..b9023a2034 --- /dev/null +++ b/spec/requests/api/v3/work_packages/create_resource_spec.rb @@ -0,0 +1,260 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2021 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Work package resource', + type: :request, + content_type: :json do + include API::V3::Utilities::PathHelper + + let(:project) do + FactoryBot.create(:project, identifier: 'test_project', public: false) + end + let(:role) { FactoryBot.create(:role, permissions: permissions) } + let(:permissions) { %i[view_work_packages edit_work_packages assign_versions] } + + current_user do + user = FactoryBot.create(:user, member_in_project: project, member_through_role: role) + + FactoryBot.create(:user_preference, user: user, others: { no_self_notified: false }) + + user + end + + describe 'POST /api/v3/work_packages' do + let(:path) { api_v3_paths.work_packages } + let(:permissions) { %i[add_work_packages view_project] } + let(:status) { FactoryBot.build(:status, is_default: true) } + let(:priority) { FactoryBot.build(:priority, is_default: true) } + let(:type) { project.types.first } + let(:parameters) do + { + subject: 'new work packages', + _links: { + type: { + href: api_v3_paths.type(type.id) + }, + project: { + href: api_v3_paths.project(project.id) + } + } + } + end + + before do + status.save! + priority.save! + + FactoryBot.create(:user_preference, user: current_user, others: { no_self_notified: false }) + perform_enqueued_jobs do + post path, parameters.to_json, 'CONTENT_TYPE' => 'application/json' + end + end + + context 'notifications' do + let(:permissions) { %i[add_work_packages view_project view_work_packages] } + + it 'sends a mail by default' do + expect(ActionMailer::Base.deliveries.size) + .to eql 1 + end + + context 'without notifications' do + let(:path) { "#{api_v3_paths.work_packages}?notify=false" } + + it 'should not send a mail' do + expect(ActionMailer::Base.deliveries.size) + .to eql 0 + end + end + + context 'with notifications' do + let(:path) { "#{api_v3_paths.work_packages}?notify=true" } + + it 'should send a mail' do + expect(ActionMailer::Base.deliveries.size) + .to eql 1 + end + end + end + + it 'should return Created(201)' do + expect(last_response.status).to eq(201) + end + + it 'should create a work package' do + expect(WorkPackage.all.count).to eq(1) + end + + it 'should use the given parameters' do + expect(WorkPackage.first.subject).to eq(parameters[:subject]) + end + + it 'should be associated with the provided project' do + expect(WorkPackage.first.project).to eq(project) + end + + it 'should be associated with the provided type' do + expect(WorkPackage.first.type).to eq(type) + end + + context 'no permissions' do + let(:current_user) { FactoryBot.create(:user) } + + it 'should hide the endpoint' do + expect(last_response.status).to eq(403) + end + end + + context 'view_project permission' do + # Note that this just removes the add_work_packages permission + # view_project is actually provided by being a member of the project + let(:permissions) { [:view_project] } + + it 'should point out the missing permission' do + expect(last_response.status).to eq(403) + end + end + + context 'empty parameters' do + let(:parameters) { {} } + + it_behaves_like 'multiple errors', 422 + + it 'should not create a work package' do + expect(WorkPackage.all.count).to eq(0) + end + end + + context 'bogus parameters' do + let(:parameters) do + { + bogus: 'bogus', + _links: { + type: { + href: api_v3_paths.type(project.types.first.id) + }, + project: { + href: api_v3_paths.project(project.id) + } + } + } + end + + it_behaves_like 'constraint violation' do + let(:message) { "Subject can't be blank" } + end + + it 'should not create a work package' do + expect(WorkPackage.all.count).to eq(0) + end + end + + context 'schedule manually' do + let(:work_package) { WorkPackage.first } + + context 'with true' do + # mind the () for the super call, those are required in rspec's super + let(:parameters) { super().merge(scheduleManually: true) } + + it 'should set the scheduling mode to true' do + expect(work_package.schedule_manually).to eq true + end + end + + context 'with false' do + let(:parameters) { super().merge(scheduleManually: false) } + + it 'should set the scheduling mode to false' do + expect(work_package.schedule_manually).to eq false + end + end + + context 'with scheduleManually absent' do + it 'should set the scheduling mode to false (default)' do + expect(work_package.schedule_manually).to eq false + end + end + end + + context 'invalid value' do + let(:parameters) do + { + subject: nil, + _links: { + type: { + href: api_v3_paths.type(project.types.first.id) + }, + project: { + href: api_v3_paths.project(project.id) + } + } + } + end + + it_behaves_like 'constraint violation' do + let(:message) { "Subject can't be blank" } + end + + it 'should not create a work package' do + expect(WorkPackage.all.count).to eq(0) + end + end + + context 'claiming attachments' do + let(:attachment) { FactoryBot.create(:attachment, container: nil, author: current_user) } + let(:parameters) do + { + subject: 'subject', + _links: { + type: { + href: api_v3_paths.type(project.types.first.id) + }, + project: { + href: api_v3_paths.project(project.id) + }, + attachments: [ + href: api_v3_paths.attachment(attachment.id) + ] + } + } + end + + it 'creates the work package and assigns the attachments' do + expect(WorkPackage.all.count).to eq(1) + + work_package = WorkPackage.last + + expect(work_package.attachments) + .to match_array(attachment) + end + end + end +end diff --git a/spec/requests/api/v3/work_packages/delete_resource_spec.rb b/spec/requests/api/v3/work_packages/delete_resource_spec.rb new file mode 100644 index 0000000000..27f8ab1773 --- /dev/null +++ b/spec/requests/api/v3/work_packages/delete_resource_spec.rb @@ -0,0 +1,102 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2021 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Work package resource', + type: :request, + content_type: :json do + include API::V3::Utilities::PathHelper + + let(:work_package) do + FactoryBot.create(:work_package, + project_id: project.id, + description: 'lorem ipsum') + end + let(:project) do + FactoryBot.create(:project, identifier: 'test_project', public: false) + end + let(:role) { FactoryBot.create(:role, permissions: permissions) } + let(:permissions) { %i[view_work_packages edit_work_packages assign_versions] } + + current_user do + user = FactoryBot.create(:user, member_in_project: project, member_through_role: role) + + FactoryBot.create(:user_preference, user: user, others: { no_self_notified: false }) + + user + end + + describe 'DELETE /api/v3/work_packages/:id' do + subject { last_response } + + let(:path) { api_v3_paths.work_package work_package.id } + + before do + delete path + end + + context 'with required permissions' do + let(:permissions) { %i[view_work_packages delete_work_packages] } + + it 'responds with HTTP No Content' do + expect(subject.status).to eq 204 + end + + it 'deletes the work package' do + expect(WorkPackage.exists?(work_package.id)).to be_falsey + end + + context 'for a non-existent work package' do + let(:path) { api_v3_paths.work_package 1337 } + + it_behaves_like 'not found' do + let(:id) { 1337 } + let(:type) { 'WorkPackage' } + end + end + end + + context 'without permission to see work packages' do + let(:permissions) { [] } + + it_behaves_like 'not found' + end + + context 'without permission to delete work packages' do + let(:permissions) { [:view_work_packages] } + + it_behaves_like 'unauthorized access' + + it 'does not delete the work package' do + expect(WorkPackage.exists?(work_package.id)).to be_truthy + end + end + end +end diff --git a/spec/requests/api/v3/work_packages/index_resource_spec.rb b/spec/requests/api/v3/work_packages/index_resource_spec.rb new file mode 100644 index 0000000000..f8f2c09cf2 --- /dev/null +++ b/spec/requests/api/v3/work_packages/index_resource_spec.rb @@ -0,0 +1,231 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2021 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Work package resource', + type: :request, + content_type: :json do + include API::V3::Utilities::PathHelper + + let(:work_package) do + FactoryBot.create(:work_package, + project_id: project.id, + description: 'lorem ipsum') + end + let(:project) do + FactoryBot.create(:project, identifier: 'test_project', public: false) + end + let(:role) { FactoryBot.create(:role, permissions: permissions) } + let(:permissions) { %i[view_work_packages edit_work_packages assign_versions] } + + current_user do + user = FactoryBot.create(:user, member_in_project: project, member_through_role: role) + + FactoryBot.create(:user_preference, user: user, others: { no_self_notified: false }) + + user + end + + describe 'GET /api/v3/work_packages' do + subject { last_response } + + let(:path) { api_v3_paths.work_packages } + let(:other_work_package) { FactoryBot.create(:work_package) } + let(:work_packages) { [work_package, other_work_package] } + + before do + work_packages + get path + end + + it 'succeeds' do + expect(subject.status).to eql 200 + end + + it 'returns visible work packages' do + expect(subject.body).to be_json_eql(1.to_json).at_path('total') + end + + it 'embedds the work package schemas' do + expect(subject.body) + .to be_json_eql(api_v3_paths.work_package_schema(project.id, work_package.type.id).to_json) + .at_path('_embedded/schemas/_embedded/elements/0/_links/self/href') + end + + context 'with filtering by typeahead' do + let(:path) { api_v3_paths.path_for :work_packages, filters: filters } + let(:filters) do + [ + { + "typeahead": { + "operator": "**", + "values": "lorem ipsum" + } + } + ] + end + + let(:lorem_ipsum_work_package) { FactoryBot.create(:work_package, project: project, subject: "lorem ipsum") } + let(:lorem_project) { FactoryBot.create(:project, members: { current_user => role }, name: "lorem other") } + let(:ipsum_work_package) { FactoryBot.create(:work_package, subject: "other ipsum", project: lorem_project) } + let(:other_lorem_work_package) { FactoryBot.create(:work_package, subject: "lorem", project: lorem_project) } + let(:work_packages) { [work_package, lorem_ipsum_work_package, ipsum_work_package, other_lorem_work_package] } + + it_behaves_like 'API V3 collection response', 2, 2, 'WorkPackage', 'WorkPackageCollection' do + let(:elements) { [lorem_ipsum_work_package, ipsum_work_package] } + end + end + + context 'with a user not seeing any work packages' do + include_context 'with non-member permissions from non_member_permissions' + let(:current_user) { FactoryBot.create(:user) } + let(:non_member_permissions) { [:view_work_packages] } + + it 'succeeds' do + expect(subject.status).to eql 200 + end + + it 'returns no work packages' do + expect(subject.body).to be_json_eql(0.to_json).at_path('total') + end + + context 'with the user not allowed to see work packages in general' do + let(:non_member_permissions) { [] } + + it_behaves_like 'unauthorized access' + end + end + + describe 'encoded query props' do + let(:props) do + eprops = { + filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, + sortBy: [%w(id asc)].to_json, + pageSize: 1 + }.to_json + + { + eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) + }.to_query + end + let(:path) { "#{api_v3_paths.work_packages}?#{props}" } + let(:other_visible_work_package) do + FactoryBot.create(:work_package, + project: project) + end + let(:another_visible_work_package) do + FactoryBot.create(:work_package, + project: project) + end + + let(:work_packages) { [work_package, other_work_package, other_visible_work_package, another_visible_work_package] } + + it 'succeeds' do + expect(subject.status) + .to eql 200 + end + + it 'returns visible and filtered work packages' do + expect(subject.body) + .to be_json_eql(2.to_json) + .at_path('total') + + # because of the page size + expect(subject.body) + .to be_json_eql(1.to_json) + .at_path('count') + + expect(subject.body) + .to be_json_eql(work_package.id.to_json) + .at_path('_embedded/elements/0/id') + end + + context 'without zlibbed' do + let(:props) do + eprops = { + filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, + sortBy: [%w(id asc)].to_json, + pageSize: 1 + }.to_json + + { + eprops: Base64.encode64(eprops) + }.to_query + end + + it_behaves_like 'param validation error' + end + + context 'non json encoded' do + let(:props) do + eprops = "some non json string" + + { + eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) + }.to_query + end + + it_behaves_like 'param validation error' + end + + context 'non base64 encoded' do + let(:props) do + eprops = { + filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, + sortBy: [%w(id asc)].to_json, + pageSize: 1 + }.to_json + + { + eprops: Zlib::Deflate.deflate(eprops) + }.to_query + end + + it_behaves_like 'param validation error' + end + + context 'non hash' do + let(:props) do + eprops = [{ + filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, + sortBy: [%w(id asc)].to_json, + pageSize: 1 + }].to_json + + { + eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) + }.to_query + end + + it_behaves_like 'param validation error' + end + end + end +end diff --git a/spec/requests/api/v3/work_packages/show_resource_spec.rb b/spec/requests/api/v3/work_packages/show_resource_spec.rb new file mode 100644 index 0000000000..de5928ff8f --- /dev/null +++ b/spec/requests/api/v3/work_packages/show_resource_spec.rb @@ -0,0 +1,210 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2021 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Work package resource', + type: :request, + content_type: :json do + include Rack::Test::Methods + include Capybara::RSpecMatchers + include API::V3::Utilities::PathHelper + + let(:closed_status) { FactoryBot.create(:closed_status) } + + let(:work_package) do + FactoryBot.create(:work_package, + project_id: project.id, + description: 'lorem ipsum') + end + let(:project) do + FactoryBot.create(:project, identifier: 'test_project', public: false) + end + let(:role) { FactoryBot.create(:role, permissions: permissions) } + let(:permissions) { %i[view_work_packages edit_work_packages assign_versions] } + let(:current_user) do + user = FactoryBot.create(:user, member_in_project: project, member_through_role: role) + + FactoryBot.create(:user_preference, user: user, others: { no_self_notified: false }) + + user + end + let(:unauthorize_user) { FactoryBot.create(:user) } + let(:type) { FactoryBot.create(:type) } + + before do + login_as(current_user) + end + + describe 'GET /api/v3/work_packages/:id' do + let(:get_path) { api_v3_paths.work_package work_package.id } + + context 'when acting as a user with permission to view work package' do + before(:each) do + login_as(current_user) + get get_path + end + + it 'should respond with 200' do + expect(last_response.status).to eq(200) + end + + describe 'response body' do + subject { last_response.body } + let!(:other_wp) do + FactoryBot.create(:work_package, + project_id: project.id, + status: closed_status) + end + let(:work_package) do + FactoryBot.create(:work_package, + project_id: project.id, + description: description).tap do |wp| + wp.children << children + end + end + let(:children) { [] } + let(:description) do + <<~DESCRIPTION + + + # OpenProject Masterplan for 2015 + + ## three point plan + + 1) One ###{other_wp.id} + 2) Two + 3) Three + + ### random thoughts + + ### things we like + + * Pointed + * Relaxed + * Debonaire + DESCRIPTION + end + + it 'responds with work package in HAL+JSON format' do + expect(subject) + .to be_json_eql(work_package.id.to_json) + .at_path('id') + end + + describe "description" do + subject { JSON.parse(last_response.body)['description'] } + + it 'renders to html' do + is_expected.to have_selector('h1') + is_expected.to have_selector('h2') + + # resolves links + expect(subject['html']) + .to have_selector("macro.macro--wp-quickinfo[data-id='#{other_wp.id}']") + # resolves macros, e.g. toc + expect(subject['html']) + .to have_selector('.op-uc-toc--list-item', text: "OpenProject Masterplan for 2015") + end + end + + describe 'derived dates' do + let(:children) do + # This will be in another project but the user is still allowed to see the dates + [FactoryBot.create(:work_package, + start_date: Date.today, + due_date: Date.today + 5.days)] + end + + it 'has derived dates' do + is_expected + .to be_json_eql(Date.today.to_json) + .at_path('derivedStartDate') + + is_expected + .to be_json_eql((Date.today + 5.days).to_json) + .at_path('derivedDueDate') + end + end + + describe 'relations' do + let(:directly_related_wp) do + FactoryBot.create(:work_package, project_id: project.id) + end + let(:transitively_related_wp) do + FactoryBot.create(:work_package, project_id: project.id) + end + + let(:work_package) do + FactoryBot.create(:work_package, + project_id: project.id, + description: 'lorem ipsum').tap do |wp| + FactoryBot.create(:relation, relates: 1, from: wp, to: directly_related_wp) + FactoryBot.create(:relation, relates: 1, from: directly_related_wp, to: transitively_related_wp) + end + end + + it 'embeds all direct relations' do + expect(subject) + .to be_json_eql(1.to_json) + .at_path('_embedded/relations/total') + + expect(subject) + .to be_json_eql(api_v3_paths.work_package(directly_related_wp.id).to_json) + .at_path('_embedded/relations/_embedded/elements/0/_links/to/href') + end + end + end + + context 'requesting nonexistent work package' do + let(:get_path) { api_v3_paths.work_package 909090 } + + it_behaves_like 'not found' + end + end + + context 'when acting as a user without permission to view work package' do + before(:each) do + allow(User).to receive(:current).and_return unauthorize_user + get get_path + end + + it_behaves_like 'not found' + end + + context 'when acting as an anonymous user' do + before(:each) do + allow(User).to receive(:current).and_return User.anonymous + get get_path + end + + it_behaves_like 'not found' + end + end +end diff --git a/spec/requests/api/v3/work_package_resource_spec.rb b/spec/requests/api/v3/work_packages/update_resource_spec.rb similarity index 62% rename from spec/requests/api/v3/work_package_resource_spec.rb rename to spec/requests/api/v3/work_packages/update_resource_spec.rb index 7f2e670ced..18a448a8d6 100644 --- a/spec/requests/api/v3/work_package_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/update_resource_spec.rb @@ -32,8 +32,6 @@ require 'rack/test' describe 'API v3 Work package resource', type: :request, content_type: :json do - include Rack::Test::Methods - include Capybara::RSpecMatchers include API::V3::Utilities::PathHelper let(:closed_status) { FactoryBot.create(:closed_status) } @@ -55,318 +53,12 @@ describe 'API v3 Work package resource', user end - let(:watcher) do - FactoryBot - .create(:user, member_in_project: project, member_through_role: role) - .tap do |user| - work_package.add_watcher(user) - end - end - let(:unauthorize_user) { FactoryBot.create(:user) } let(:type) { FactoryBot.create(:type) } before do login_as(current_user) end - describe 'GET /api/v3/work_packages' do - subject { last_response } - - let(:path) { api_v3_paths.work_packages } - let(:other_work_package) { FactoryBot.create(:work_package) } - let(:work_packages) { [work_package, other_work_package] } - - before(:each) do - work_packages - get path - end - - it 'succeeds' do - expect(subject.status).to eql 200 - end - - it 'returns visible work packages' do - expect(subject.body).to be_json_eql(1.to_json).at_path('total') - end - - it 'embedds the work package schemas' do - expect(subject.body) - .to be_json_eql(api_v3_paths.work_package_schema(project.id, work_package.type.id).to_json) - .at_path('_embedded/schemas/_embedded/elements/0/_links/self/href') - end - - context 'user not seeing any work packages' do - include_context 'with non-member permissions from non_member_permissions' - let(:current_user) { FactoryBot.create(:user) } - let(:non_member_permissions) { [:view_work_packages] } - - it 'succeeds' do - expect(subject.status).to eql 200 - end - - it 'returns no work packages' do - expect(subject.body).to be_json_eql(0.to_json).at_path('total') - end - - context 'because he is not allowed to see work packages in general' do - let(:non_member_permissions) { [] } - - it_behaves_like 'unauthorized access' - end - end - - describe 'encoded query props' do - let(:props) do - eprops = { - filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, - sortBy: [%w(id asc)].to_json, - pageSize: 1 - }.to_json - - { - eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) - }.to_query - end - let(:path) { "#{api_v3_paths.work_packages}?#{props}" } - let(:other_visible_work_package) do - FactoryBot.create(:work_package, - project: project) - end - let(:another_visible_work_package) do - FactoryBot.create(:work_package, - project: project) - end - - let(:work_packages) { [work_package, other_work_package, other_visible_work_package, another_visible_work_package] } - - it 'succeeds' do - expect(subject.status) - .to eql 200 - end - - it 'returns visible and filtered work packages' do - expect(subject.body) - .to be_json_eql(2.to_json) - .at_path('total') - - # because of the page size - expect(subject.body) - .to be_json_eql(1.to_json) - .at_path('count') - - expect(subject.body) - .to be_json_eql(work_package.id.to_json) - .at_path('_embedded/elements/0/id') - end - - context 'non zlibbed' do - let(:props) do - eprops = { - filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, - sortBy: [%w(id asc)].to_json, - pageSize: 1 - }.to_json - - { - eprops: Base64.encode64(eprops) - }.to_query - end - - it_behaves_like 'param validation error' - end - - context 'non json encoded' do - let(:props) do - eprops = "some non json string" - - { - eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) - }.to_query - end - - it_behaves_like 'param validation error' - end - - context 'non base64 encoded' do - let(:props) do - eprops = { - filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, - sortBy: [%w(id asc)].to_json, - pageSize: 1 - }.to_json - - { - eprops: Zlib::Deflate.deflate(eprops) - }.to_query - end - - it_behaves_like 'param validation error' - end - - context 'non hash' do - let(:props) do - eprops = [{ - filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, - sortBy: [%w(id asc)].to_json, - pageSize: 1 - }].to_json - - { - eprops: Base64.encode64(Zlib::Deflate.deflate(eprops)) - }.to_query - end - - it_behaves_like 'param validation error' - end - end - end - - describe 'GET /api/v3/work_packages/:id' do - let(:get_path) { api_v3_paths.work_package work_package.id } - - context 'when acting as a user with permission to view work package' do - before(:each) do - login_as(current_user) - get get_path - end - - it 'should respond with 200' do - expect(last_response.status).to eq(200) - end - - describe 'response body' do - subject { last_response.body } - let!(:other_wp) do - FactoryBot.create(:work_package, - project_id: project.id, - status: closed_status) - end - let(:work_package) do - FactoryBot.create(:work_package, - project_id: project.id, - description: description).tap do |wp| - wp.children << children - end - end - let(:children) { [] } - let(:description) do - <<~DESCRIPTION - - - # OpenProject Masterplan for 2015 - - ## three point plan - - 1) One ###{other_wp.id} - 2) Two - 3) Three - - ### random thoughts - - ### things we like - - * Pointed - * Relaxed - * Debonaire - DESCRIPTION - end - - it 'responds with work package in HAL+JSON format' do - expect(subject) - .to be_json_eql(work_package.id.to_json) - .at_path('id') - end - - describe "description" do - subject { JSON.parse(last_response.body)['description'] } - - it 'renders to html' do - is_expected.to have_selector('h1') - is_expected.to have_selector('h2') - - # resolves links - expect(subject['html']) - .to have_selector("macro.macro--wp-quickinfo[data-id='#{other_wp.id}']") - # resolves macros, e.g. toc - expect(subject['html']) - .to have_selector('.op-uc-toc--list-item', text: "OpenProject Masterplan for 2015") - end - end - - describe 'derived dates' do - let(:children) do - # This will be in another project but the user is still allowed to see the dates - [FactoryBot.create(:work_package, - start_date: Date.today, - due_date: Date.today + 5.days)] - end - - it 'has derived dates' do - is_expected - .to be_json_eql(Date.today.to_json) - .at_path('derivedStartDate') - - is_expected - .to be_json_eql((Date.today + 5.days).to_json) - .at_path('derivedDueDate') - end - end - - describe 'relations' do - let(:directly_related_wp) do - FactoryBot.create(:work_package, project_id: project.id) - end - let(:transitively_related_wp) do - FactoryBot.create(:work_package, project_id: project.id) - end - - let(:work_package) do - FactoryBot.create(:work_package, - project_id: project.id, - description: 'lorem ipsum').tap do |wp| - FactoryBot.create(:relation, relates: 1, from: wp, to: directly_related_wp) - FactoryBot.create(:relation, relates: 1, from: directly_related_wp, to: transitively_related_wp) - end - end - - it 'embeds all direct relations' do - expect(subject) - .to be_json_eql(1.to_json) - .at_path('_embedded/relations/total') - - expect(subject) - .to be_json_eql(api_v3_paths.work_package(directly_related_wp.id).to_json) - .at_path('_embedded/relations/_embedded/elements/0/_links/to/href') - end - end - end - - context 'requesting nonexistent work package' do - let(:get_path) { api_v3_paths.work_package 909090 } - - it_behaves_like 'not found' - end - end - - context 'when acting as a user without permission to view work package' do - before(:each) do - allow(User).to receive(:current).and_return unauthorize_user - get get_path - end - - it_behaves_like 'not found' - end - - context 'when acting as an anonymous user' do - before(:each) do - allow(User).to receive(:current).and_return User.anonymous - get get_path - end - - it_behaves_like 'not found' - end - end - describe 'PATCH /api/v3/work_packages/:id' do let(:patch_path) { api_v3_paths.work_package work_package.id } let(:valid_params) do @@ -416,7 +108,7 @@ describe 'API v3 Work package resource', it { expect(subject.body) .to be_json_eql(work_package.reload.lock_version) - .at_path('lockVersion') + .at_path('lockVersion') } end @@ -443,7 +135,7 @@ describe 'API v3 Work package resource', it do expect(Notifications::JournalCompletedJob) .to have_been_enqueued - .at_least(1) + .at_least(1) end end @@ -454,7 +146,7 @@ describe 'API v3 Work package resource', it do expect(Notifications::JournalCompletedJob) .to have_been_enqueued - .at_least(1) + .at_least(1) end end end @@ -482,7 +174,7 @@ describe 'API v3 Work package resource', it 'has a readonly error' do expect(response.body) .to be_json_eql('urn:openproject-org:api:v3:errors:PropertyIsReadOnly'.to_json) - .at_path('errorIdentifier') + .at_path('errorIdentifier') end end end @@ -743,7 +435,7 @@ describe 'API v3 Work package resource', it 'responds with the new custom field having the desired value' do expect(subject.body) .to be_json_eql(true.to_json) - .at_path("customField#{custom_field.id}") + .at_path("customField#{custom_field.id}") end end end @@ -802,7 +494,7 @@ describe 'API v3 Work package resource', it { expect(response.body) .to be_json_eql(title) - .at_path("_links/#{property}/title") + .at_path("_links/#{property}/title") } it_behaves_like 'lock version updated' @@ -909,7 +601,7 @@ describe 'API v3 Work package resource', it 'should respond with the work package assigned to the version' do expect(subject.body) .to be_json_eql(target_version.name.to_json) - .at_path('_embedded/version/name') + .at_path('_embedded/version/name') end it_behaves_like 'lock version updated' @@ -925,7 +617,7 @@ describe 'API v3 Work package resource', it 'has a readonly error' do expect(response.body) .to be_json_eql('urn:openproject-org:api:v3:errors:PropertyIsReadOnly'.to_json) - .at_path('errorIdentifier') + .at_path('errorIdentifier') end end end @@ -946,7 +638,7 @@ describe 'API v3 Work package resource', it 'should respond with the work package assigned to the category' do expect(subject.body) .to be_json_eql(target_category.name.to_json) - .at_path('_embedded/category/name') + .at_path('_embedded/category/name') end it_behaves_like 'lock version updated' @@ -969,7 +661,7 @@ describe 'API v3 Work package resource', it 'should respond with the work package assigned to the priority' do expect(subject.body) .to be_json_eql(target_priority.name.to_json) - .at_path('_embedded/priority/name') + .at_path('_embedded/priority/name') end it_behaves_like 'lock version updated' @@ -1037,7 +729,7 @@ describe 'API v3 Work package resource', it 'should respond with the work package assigned to the new value' do expect(subject.body) .to be_json_eql(value_link.to_json) - .at_path("_links/#{custom_field.accessor_name.camelize(:lower)}/href") + .at_path("_links/#{custom_field.accessor_name.camelize(:lower)}/href") end it_behaves_like 'lock version updated' @@ -1175,261 +867,4 @@ describe 'API v3 Work package resource', end end end - - describe 'DELETE /api/v3/work_packages/:id' do - let(:path) { api_v3_paths.work_package work_package.id } - - before do - delete path - end - - subject { last_response } - - context 'with required permissions' do - let(:permissions) { %i[view_work_packages delete_work_packages] } - - it 'responds with HTTP No Content' do - expect(subject.status).to eq 204 - end - - it 'deletes the work package' do - expect(WorkPackage.exists?(work_package.id)).to be_falsey - end - - context 'for a non-existent work package' do - let(:path) { api_v3_paths.work_package 1337 } - - it_behaves_like 'not found' do - let(:id) { 1337 } - let(:type) { 'WorkPackage' } - end - end - end - - context 'without permission to see work packages' do - let(:permissions) { [] } - - it_behaves_like 'not found' - end - - context 'without permission to delete work packages' do - let(:permissions) { [:view_work_packages] } - - it_behaves_like 'unauthorized access' - - it 'does not delete the work package' do - expect(WorkPackage.exists?(work_package.id)).to be_truthy - end - end - end - - describe 'POST /api/v3/work_packages' do - let(:path) { api_v3_paths.work_packages } - let(:permissions) { %i[add_work_packages view_project] } - let(:status) { FactoryBot.build(:status, is_default: true) } - let(:priority) { FactoryBot.build(:priority, is_default: true) } - let(:type) { project.types.first } - let(:parameters) do - { - subject: 'new work packages', - _links: { - type: { - href: api_v3_paths.type(type.id) - }, - project: { - href: api_v3_paths.project(project.id) - } - } - } - end - - before do - status.save! - priority.save! - - FactoryBot.create(:user_preference, user: current_user, others: { no_self_notified: false }) - perform_enqueued_jobs do - post path, parameters.to_json, 'CONTENT_TYPE' => 'application/json' - end - end - - context 'notifications' do - let(:permissions) { %i[add_work_packages view_project view_work_packages] } - - it 'sends a mail by default' do - expect(ActionMailer::Base.deliveries.size) - .to eql 1 - end - - context 'without notifications' do - let(:path) { "#{api_v3_paths.work_packages}?notify=false" } - - it 'should not send a mail' do - expect(ActionMailer::Base.deliveries.size) - .to eql 0 - end - end - - context 'with notifications' do - let(:path) { "#{api_v3_paths.work_packages}?notify=true" } - - it 'should send a mail' do - expect(ActionMailer::Base.deliveries.size) - .to eql 1 - end - end - end - - it 'should return Created(201)' do - expect(last_response.status).to eq(201) - end - - it 'should create a work package' do - expect(WorkPackage.all.count).to eq(1) - end - - it 'should use the given parameters' do - expect(WorkPackage.first.subject).to eq(parameters[:subject]) - end - - it 'should be associated with the provided project' do - expect(WorkPackage.first.project).to eq(project) - end - - it 'should be associated with the provided type' do - expect(WorkPackage.first.type).to eq(type) - end - - context 'no permissions' do - let(:current_user) { FactoryBot.create(:user) } - - it 'should hide the endpoint' do - expect(last_response.status).to eq(403) - end - end - - context 'view_project permission' do - # Note that this just removes the add_work_packages permission - # view_project is actually provided by being a member of the project - let(:permissions) { [:view_project] } - - it 'should point out the missing permission' do - expect(last_response.status).to eq(403) - end - end - - context 'empty parameters' do - let(:parameters) { {} } - - it_behaves_like 'multiple errors', 422 - - it 'should not create a work package' do - expect(WorkPackage.all.count).to eq(0) - end - end - - context 'bogus parameters' do - let(:parameters) do - { - bogus: 'bogus', - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - }, - project: { - href: api_v3_paths.project(project.id) - } - } - } - end - - it_behaves_like 'constraint violation' do - let(:message) { "Subject can't be blank" } - end - - it 'should not create a work package' do - expect(WorkPackage.all.count).to eq(0) - end - end - - context 'schedule manually' do - let(:work_package) { WorkPackage.first } - - context 'with true' do - # mind the () for the super call, those are required in rspec's super - let(:parameters) { super().merge(scheduleManually: true) } - - it 'should set the scheduling mode to true' do - expect(work_package.schedule_manually).to eq true - end - end - - context 'with false' do - let(:parameters) { super().merge(scheduleManually: false) } - - it 'should set the scheduling mode to false' do - expect(work_package.schedule_manually).to eq false - end - end - - context 'with scheduleManually absent' do - it 'should set the scheduling mode to false (default)' do - expect(work_package.schedule_manually).to eq false - end - end - end - - context 'invalid value' do - let(:parameters) do - { - subject: nil, - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - }, - project: { - href: api_v3_paths.project(project.id) - } - } - } - end - - it_behaves_like 'constraint violation' do - let(:message) { "Subject can't be blank" } - end - - it 'should not create a work package' do - expect(WorkPackage.all.count).to eq(0) - end - end - - context 'claiming attachments' do - let(:attachment) { FactoryBot.create(:attachment, container: nil, author: current_user) } - let(:parameters) do - { - subject: 'subject', - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - }, - project: { - href: api_v3_paths.project(project.id) - }, - attachments: [ - href: api_v3_paths.attachment(attachment.id) - ] - } - } - end - - it 'creates the work package and assigns the attachments' do - expect(WorkPackage.all.count).to eq(1) - - work_package = WorkPackage.last - - expect(work_package.attachments) - .to match_array(attachment) - end - end - end end