Create page command and service

Wiring up actual page creation to the dialog that has been prepared
beforehand.

The commands are merely responsible for creating pages in the corresponding
wiki provider. The new CreateLinkedPageService takes care of combining
the two steps needed by the UI:

1. Creating the page
2. Linking it
This commit is contained in:
Jan Sandbrink
2026-06-10 16:36:35 +02:00
parent 1c736fb887
commit 06c462112e
18 changed files with 1072 additions and 30 deletions
@@ -0,0 +1,50 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
# Allows to use the service as a source for ActiveModel::Errors, i.e.
#
# ActiveModel::Errors.new(self)
#
# It adds neccessary translation and naming helpers to the service.
module ServiceAsErrorSource
def self.included(base)
base.extend ActiveModel::Naming
base.extend ActiveModel::Translation
base.extend ClassMethods
end
module ClassMethods
def i18n_scope = "services"
def model_name = ActiveModel::Name.new(self)
end
def read_attribute_for_validation(attr) = attr
end
@@ -0,0 +1,45 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis
module Adapters
module Input
class CreatePageContract < DryApplicationContract
params do
required(:title).filled(:string)
# TODO: This only works for pages as parents right now, we'll need to change some things around to support
# creating "root" pages that are direct children of a wiki. E.g. right now there is no data type to represent a wiki
required(:parent_identifier).filled(:string)
end
end
end
end
end
@@ -0,0 +1,49 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis
module Concerns
module LinkableRedirect
def turbo_redirect_for_linkable(linkable)
path = derive_path_from_linkable(linkable)
return redirect_to path, status: :see_other if path
head :no_content
end
def derive_path_from_linkable(linkable)
case linkable
when WorkPackage
project_work_package_wikis_tab_index_path(work_package_id: linkable.id, project_id: linkable.project_id)
end
end
end
end
end
@@ -30,6 +30,7 @@
module Wikis
class PagesController < ApplicationController
include Concerns::LinkableRedirect
include OpTurbo::ComponentStream
include Dry::Monads[:result]
@@ -39,12 +40,23 @@ module Wikis
# the permissions set in each wiki.
no_authorization_required! :search
def create_and_link
# TODO: implement service to create page and link
render_error_flash_message_via_turbo_stream(
message: "Not implemented yet. Trying to create a new page with #{create_new_page_params.to_h}"
)
respond_to_with_turbo_streams
def create_and_link # rubocop:disable Metrics/AbcSize
provider = Provider.visible.find(create_new_page_params[:provider_id])
result = CreateLinkedPageService.new(provider:, user: current_user)
.call(
title: create_new_page_params[:page_title],
parent_identifier: create_new_page_params[:parent_page_identifier],
linkable_type: create_new_page_params[:linkable_type],
linkable_id: create_new_page_params[:linkable_id]
)
if result.success?
turbo_redirect_for_linkable(result.result.linkable)
else
message = result.errors.full_messages.join(" ")
render_error_flash_message_via_turbo_stream(message:)
respond_to_with_turbo_streams
end
end
def create_new_page_dialog
@@ -30,6 +30,7 @@
module Wikis
class RelationPageLinksController < ApplicationController
include Concerns::LinkableRedirect
include OpTurbo::ComponentStream
before_action :authorize
@@ -88,19 +89,5 @@ module Wikis
nil
end
end
def turbo_redirect_for_linkable(linkable)
path = derive_path_from_linkable(linkable)
return redirect_to path, status: :see_other if path
head :no_content
end
def derive_path_from_linkable(linkable)
case linkable
when WorkPackage
project_work_package_wikis_tab_index_path(work_package_id: linkable.id, project_id: linkable.project_id)
end
end
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis::Adapters::Input
CreatePage = Data.define(:title, :parent_identifier) do
private_class_method :new
def self.build(title:, parent_identifier:, contract: CreatePageContract.new)
contract.call(title:, parent_identifier:).to_monad.fmap { new(**it.to_h) }
end
end
end
@@ -0,0 +1,55 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis::Adapters
class BaseCommand
include Dry::Monads[:result]
attr_reader :provider
def initialize(model:)
@provider = model
end
def call(input_data:, auth_strategy:) # rubocop:disable Lint/UnusedMethodArgument
raise SubclassResponsibilityError
end
private
def success(result)
Success(result)
end
def failure(code:)
Failure(Results::Error.new(source: self.class, code:))
end
end
end
@@ -0,0 +1,74 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis
module Adapters
module Providers
module Internal
module Commands
class CreatePage < BaseCommand
def call(input_data:, auth_strategy:)
Adapters::Authentication[auth_strategy].call do |user|
parent = find_parent(input_data.parent_identifier, user:)
return failure(code: :not_found) if parent.nil?
service_result_to_monad(
WikiPages::CreateService.new(user:).call(
title: input_data.title,
parent:,
wiki: parent.wiki
)
)
end
end
private
def find_parent(identifier, user:)
WikiPage.visible(user).find_by(id: identifier)
end
def service_result_to_monad(result)
if result.success?
success(Queries::PageInfo.wiki_page_to_page_info(result.result, provider:))
elsif result.errors.details.values.flatten.any? { |e| e.fetch(:error) == :error_unauthorized }
failure(code: :forbidden)
else
# for now simplifying to a single error code, since there is not really any
# error case expected to crop up during real usage, due to previous validations in upstream code
failure(code: :invalid)
end
end
end
end
end
end
end
end
@@ -38,15 +38,7 @@ module Wikis
end
namespace("commands") do
# ...
end
namespace("components") do
# ...
end
namespace("contracts") do
# ...
register(:create_page, Commands::CreatePage)
end
namespace("queries") do
@@ -0,0 +1,84 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis
module Adapters
module Providers
module XWiki
module Commands
class CreatePage < BaseCommand
include Concerns::XWikiRequest
class << self
# We have to manually derive a useful ID from the title that's both valid, but also makes for a nice XWiki URL
def derive_page_id(title)
title.gsub(/[\\.:]/, { "\\" => "\\\\", ":" => "\\:", "." => "\\." })
end
end
def call(input_data:, auth_strategy:)
parent_result = fetch_canonical_parent(identifier: input_data.parent_identifier, auth_strategy:)
parent_result.bind do |canonical_parent|
identifier = "#{canonical_parent}.#{self.class.derive_page_id(input_data.title)}.WebHome"
create_page_request(identifier, title: input_data.title, auth_strategy:) do |data|
success(Queries::StablePageInfo.json_to_page_info(data, provider:))
end
end
end
private
def fetch_canonical_parent(identifier:, auth_strategy:)
ref = StablePageReference.parse(identifier)
return failure(code: :not_found) unless ref
authenticated(auth_strategy) do |http|
handle_response(http.get(rest_url(ref.rest_path))) do |data|
success("#{fetch_json(data, 'wiki')}:#{fetch_json(data, 'space')}")
end
end
end
def create_page_request(reference, title:, auth_strategy:, &)
authenticated(auth_strategy) do |http|
handle_response(
http.with(headers: { "Content-Type": "application/json" })
.put(rest_url("openproject/documents", query: { docRef: reference.to_s }), body: { title: }.to_json),
&
)
end
end
end
end
end
end
end
end
@@ -38,7 +38,7 @@ module Wikis
end
namespace("commands") do
# ...
register(:create_page, Commands::CreatePage)
end
namespace("components") do
@@ -0,0 +1,79 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
module Wikis
class CreateLinkedPageService
include ServiceAsErrorSource
include Dry::Monads[:result]
attr_reader :provider, :user
def initialize(provider:, user:)
@provider = provider
@user = user
end
def call(title:, parent_identifier:, linkable_type:, linkable_id:)
dry_result = create_page(title:, parent_identifier:).bind do |page_info|
link_page(identifier: page_info.identifier, linkable_type:, linkable_id:)
end
dry_result.value_or do |error|
ServiceResult.failure(errors: ActiveModel::Errors.new(self)).tap do |result|
error_target = error.code == :missing_token ? :base : :wiki_page
result.errors.add(error_target, error.code)
end
end
end
private
def create_page(title:, parent_identifier:)
provider.auth_strategy_for(user).bind do |auth_strategy|
Adapters::Input::CreatePage.build(title:, parent_identifier:).bind do |input_data|
provider.resolve("commands.create_page").call(input_data:, auth_strategy:)
end
end
end
def link_page(identifier:, linkable_type:, linkable_id:)
create_service = RelationPageLinks::CreateService.new(user:)
Success(
create_service.call(
provider_id: provider.id,
linkable_type:,
linkable_id:,
author_id: user.id,
identifier:
)
)
end
end
end
+10
View File
@@ -38,6 +38,16 @@ en:
wikis: "Wikis"
permission_manage_wiki_page_links: Manage Wiki Page Links
project_module_wiki_platforms: Wiki providers
services:
attributes:
wikis/create_linked_page_service:
wiki_page: Wiki page
errors:
models:
wikis/create_linked_page_service:
forbidden: was not allowed to be created.
not_found: was not found.
token_missing: Page creation not possible, user account is not connected to the wiki provider.
wikis:
admin:
destroy_confirmation_dialog_component:
@@ -0,0 +1,109 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require_module_spec_helper
RSpec.describe Wikis::Adapters::Providers::Internal::Commands::CreatePage do
subject(:result) { described_class.new(model: provider).call(input_data:, auth_strategy:) }
let(:provider) { create(:internal_wiki_provider) }
let(:auth_strategy) { Wikis::Adapters::Input::AuthStrategy.build(key: :internal, user:, provider:).value! }
let(:input_data) { Wikis::Adapters::Input::CreatePage.build(title:, parent_identifier:).value! }
let(:user) { create(:user) }
let(:title) { "A page automatically created during a create_page test" }
let(:parent_identifier) { existing_page.id.to_s }
let(:existing_page) { create(:wiki_page) }
let(:project) { existing_page.project }
let(:permissions) { %i[view_wiki_pages edit_wiki_pages] }
before do
create(:member, project:, user:, roles: [create(:project_role, permissions:)])
end
it { is_expected.to be_success }
it "successfully creates a page" do
expect { subject }.to change(WikiPage, :count).by(1)
expect(WikiPage.last.title).to eq(title)
end
it "makes the page a child of the intended parent" do
subject
expect(WikiPage.last.parent).to eq(existing_page)
end
it "returns the page info of the created page" do
expect(subject.value!.identifier).to eq(WikiPage.last.id.to_s)
expect(subject.value!.title).to eq(title)
end
context "when the parent does not exist" do
let(:parent_identifier) { (existing_page.id * 10).to_s }
it "returns a :not_found error" do
expect(result).to be_failure
expect(result.failure.code).to eq(:not_found)
end
it "does not create a page" do
expect { subject }.not_to change(WikiPage, :count)
end
end
context "when the parent is not visible to the user" do
let(:permissions) { %i[edit_wiki_pages] }
it "returns a :not_found error" do
expect(result).to be_failure
expect(result.failure.code).to eq(:not_found)
end
it "does not create a page" do
expect { subject }.not_to change(WikiPage, :count)
end
end
context "when user is not allowed to create wiki pages" do
let(:permissions) { %i[view_wiki_pages] }
it "returns a :forbidden error" do
expect(result).to be_failure
expect(result.failure.code).to eq(:forbidden)
end
it "does not create a page" do
expect { subject }.not_to change(WikiPage, :count)
end
end
end
@@ -0,0 +1,122 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require_module_spec_helper
RSpec.describe Wikis::Adapters::Providers::XWiki::Commands::CreatePage, :disable_ssrf_filter, :webmock do
describe "#call" do
subject(:result) { described_class.new(model: provider).call(input_data:, auth_strategy:) }
let(:provider) { create(:xwiki_provider, :for_local_connection, connected_user: user) }
let(:auth_strategy) { Wikis::Adapters::Input::AuthStrategy.build(key: :bearer_token, user:, provider:).value! }
let(:input_data) { Wikis::Adapters::Input::CreatePage.build(title:, parent_identifier:).value! }
let(:user) { create(:user) }
let(:title) { "A page automatically created during a create_page test" }
# To record a VCR cassette, make sure to set parent_identifier to the stable ID of an existing wiki page
# and parent_title to that wiki page's title.
# Pages created in the run of these tests should be deleted again afterwards for repeatability
let(:parent_identifier) { "d65fa" }
let(:parent_title) { "Test Page for RSpec" }
it "successfully creates a page", vcr: "xwiki/create_page_and_confirm" do
expect(result).to be_success
id = result.value!.identifier
input = Wikis::Adapters::Input::PageInfo.build(identifier: id).value!
confirmation = provider.resolve("queries.page_info").call(input_data: input, auth_strategy:)
expect(confirmation).to be_success
expect(confirmation.value!.title).to eq(title)
aggregate_failures "making the page a child of the intended parent" do
expected_prefix = Regexp.new("^#{Regexp.escape("xwiki:#{parent_title}.")}")
expect(WebMock).to have_requested(:put, "https://xwiki.local/rest/openproject/documents")
.with(query: hash_including(docRef: expected_prefix))
end
aggregate_failures "allowing the created page to have child pages" do
expected_suffix = /\.WebHome$/
expect(WebMock).to have_requested(:put, "https://xwiki.local/rest/openproject/documents")
.with(query: hash_including(docRef: expected_suffix))
end
end
context "when the parent does not exist", vcr: "xwiki/create_page_not_found" do
let(:parent_identifier) { "abc123" }
it "returns a :not_found error" do
expect(result).to be_failure
expect(result.failure.code).to eq(:not_found)
end
it "does not create a page" do
result
expect(WebMock).not_to have_requested(:put, %r{https://xwiki.local/rest/openproject/documents})
end
end
end
describe ".derive_page_id" do
subject { described_class.derive_page_id(title) }
let(:title) { "My simple title" }
it "does not interfere with the title" do
expect(subject).to eq(title)
end
context "when the title contains emoji and umlauts" do
let(:title) { "Mein schöner Titel 🐈" }
it "does not interfere with the title" do
expect(subject).to eq(title)
end
end
context "when the title contains colons or dots" do
let(:title) { "Release notes: 1.0.0" }
it "escapes colons and dots" do
expect(subject).to eq("Release notes\\: 1\\.0\\.0")
end
end
context "when the title contains backslashes" do
let(:title) { "C:\\Windows\\System32 is a windows-style path" }
it "escapes backslashes" do
expect(subject).to eq("C\\:\\\\Windows\\\\System32 is a windows-style path")
end
end
end
end
@@ -0,0 +1,131 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require_module_spec_helper
module Wikis
RSpec.describe CreateLinkedPageService do
include Dry::Monads[:result]
subject(:service_result) do
described_class.new(provider:, user:).call(
title:,
parent_identifier:,
linkable_type: linkable.class.name,
linkable_id: linkable.id
)
end
let(:user) { build_stubbed(:user) }
let(:linkable) { build_stubbed(:work_package) }
let(:title) { "My Page" }
let(:parent_identifier) { "MySpace.Parent" }
let(:page_identifier) { "#{parent_identifier}.MyPage" }
let(:provider) { instance_double(Provider, id: 1) }
let(:auth_strategy) { instance_double(Adapters::AuthenticationStrategies::BearerToken) }
let(:create_page_command) { instance_double(Adapters::Providers::XWiki::Commands::CreatePage) }
let(:page_info) do
Adapters::Results::PageInfo.new(identifier: page_identifier, title:,
href: "https://wiki.example.com/MyPage", provider:)
end
let(:page_link) { build_stubbed(:relation_wiki_page_link) }
let(:create_link_service) { instance_double(RelationPageLinks::CreateService) }
let(:create_link_result) { ServiceResult.success(result: page_link) }
before do
allow(provider).to receive(:auth_strategy_for).with(user).and_return(Success(auth_strategy))
allow(provider).to receive(:resolve).with("commands.create_page").and_return(create_page_command)
allow(create_page_command).to receive(:call)
.with(input_data: anything, auth_strategy: auth_strategy)
.and_return(Success(page_info))
allow(RelationPageLinks::CreateService).to receive(:new).with(user:).and_return(create_link_service)
allow(create_link_service).to receive(:call).and_return(create_link_result)
end
context "when all steps succeed" do
it "returns a successful service result" do
expect(service_result).to be_success
end
it "returns the created page link as the result" do
expect(service_result.result).to eq(page_link)
end
it "creates a page link with the page identifier from the command result" do
service_result
expect(create_link_service).to have_received(:call).with(
provider_id: provider.id,
linkable_type: linkable.class.name,
linkable_id: linkable.id,
author_id: user.id,
identifier: page_identifier
)
end
end
context "when the create link service fails" do
let(:create_link_result) { ServiceResult.failure }
it "returns the failure" do
expect(service_result).to eq(create_link_result)
end
end
context "when auth strategy fails" do
before do
allow(provider).to receive(:auth_strategy_for).with(user)
.and_return(Failure(Adapters::Results::Error.new(source: self, code: :missing_token)))
end
it "returns a failure service result with the auth error code" do
expect(service_result).to be_failure
expect(service_result.errors.where(:base).map(&:type)).to include(:missing_token)
end
end
context "when the create page command fails" do
let(:command_error) { Adapters::Results::Error.new(source: Adapters::Providers::XWiki::Commands::CreatePage, code: :not_found) }
before do
allow(create_page_command).to receive(:call).and_return(Failure(command_error))
end
it "returns a failure service result" do
expect(service_result).to be_failure
end
it "adds the command error code to the wiki_page attribute" do
expect(service_result.errors.where(:wiki_page).map(&:type)).to include(:not_found)
end
end
end
end
@@ -0,0 +1,152 @@
---
http_interactions:
- request:
method: get
uri: https://xwiki.local/rest/openproject/documents/d65fa
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.6.0 HTTPX Client
Accept:
- application/json
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 200
message: OK
headers:
Content-Language:
- en
Content-Script-Type:
- text/javascript
Content-Type:
- application/json;charset=UTF-8
Date:
- Fri, 12 Jun 2026 07:01:29 GMT
Set-Cookie:
- JSESSIONID=6533D0723B8276B5E0C9F0DF53503F6D; Path=/; HttpOnly
Xwiki-Form-Token:
- Lgex3rjqeBxHH0hyu8cGBQ
Xwiki-User:
- xwiki:XWiki.admin
Xwiki-Version:
- 18.3.0
Content-Length:
- '2540'
body:
encoding: UTF-8
string: '{"links":[{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec","rel":"http://www.xwiki.org/rel/space","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/pages/WebHome","rel":"http://www.xwiki.org/rel/parent","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/pages/WebHome/history","rel":"http://www.xwiki.org/rel/history","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/pages/WebHome/objects","rel":"http://www.xwiki.org/rel/objects","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/syntaxes","rel":"http://www.xwiki.org/rel/syntaxes","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/openproject/documents/d65fa","rel":"self","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/classes/Test%20Page%20for%20RSpec.WebHome","rel":"http://www.xwiki.org/rel/class","type":null,"hrefLang":null}],"id":"d65fa","fullName":"Test
Page for RSpec.WebHome","wiki":"xwiki","space":"Test Page for RSpec","name":"WebHome","title":"Test
Page for RSpec","rawTitle":"Test Page for RSpec","parent":"Main.WebHome","parentId":"xwiki:Main.WebHome","version":"1.1","author":"XWiki.admin","authorName":null,"xwikiRelativeUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/","xwikiAbsoluteUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/","translations":{"links":[],"translations":[],"default":"en"},"syntax":"xwiki/2.1","language":"","majorVersion":1,"minorVersion":1,"hidden":false,"enforceRequiredRights":false,"created":1781247375000,"creator":"XWiki.admin","creatorName":null,"modified":1781247375000,"modifier":"XWiki.admin","modifierName":null,"originalMetadataAuthor":"xwiki:XWiki.admin","originalMetadataAuthorName":null,"comment":"Created
URL Shortener.","content":"I recreated this page, but it is for RSpec still.","clazz":null,"objects":null,"attachments":null,"hierarchy":{"items":[{"label":"xwiki","name":"xwiki","type":"wiki","url":"https://xwiki.local/bin/view/Main/"},{"label":"Test
Page for RSpec","name":"Test Page for RSpec","type":"space","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/"},{"label":"WebHome","name":"WebHome","type":"document","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/"}]},"rights":[],"renderedContent":null}'
recorded_at: Fri, 12 Jun 2026 07:01:29 GMT
- request:
method: put
uri: https://xwiki.local/rest/openproject/documents?docRef=xwiki:Test%20Page%20for%20RSpec.A%20page%20automatically%20created%20during%20a%20create_page%20test.WebHome
body:
encoding: UTF-8
string: '{"title":"A page automatically created during a create_page test"}'
headers:
User-Agent:
- OpenProject 17.6.0 HTTPX Client
Accept:
- application/json
Accept-Encoding:
- gzip, deflate
Content-Type:
- application/json
Content-Length:
- '66'
Authorization:
- Bearer <SECRET>
response:
status:
code: 201
message: Created
headers:
Content-Language:
- en
Content-Script-Type:
- text/javascript
Content-Type:
- application/json;charset=UTF-8
Date:
- Fri, 12 Jun 2026 07:01:29 GMT
Set-Cookie:
- JSESSIONID=ADC41F7E6B022B280D38635BE03C2FF1; Path=/; HttpOnly
Xwiki-Form-Token:
- Lgex3rjqeBxHH0hyu8cGBQ
Xwiki-User:
- xwiki:XWiki.admin
Xwiki-Version:
- 18.3.0
Content-Length:
- '2974'
body:
encoding: UTF-8
string: '{"links":[{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/spaces/A%20page%20automatically%20created%20during%20a%20create_page%20test","rel":"http://www.xwiki.org/rel/space","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/spaces/A%20page%20automatically%20created%20during%20a%20create_page%20test/pages/WebHome/history","rel":"http://www.xwiki.org/rel/history","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/syntaxes","rel":"http://www.xwiki.org/rel/syntaxes","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/openproject/documents","rel":"self","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/classes/Test%20Page%20for%20RSpec.A%20page%20automatically%20created%20during%20a%20create_page%20test.WebHome","rel":"http://www.xwiki.org/rel/class","type":null,"hrefLang":null}],"id":"91d8c","fullName":"Test
Page for RSpec.A page automatically created during a create_page test.WebHome","wiki":"xwiki","space":"Test
Page for RSpec.A page automatically created during a create_page test","name":"WebHome","title":"A
page automatically created during a create_page test","rawTitle":"A page automatically
created during a create_page test","parent":"","parentId":"","version":"1.1","author":"XWiki.admin","authorName":null,"xwikiRelativeUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/","xwikiAbsoluteUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/","translations":{"links":[],"translations":[],"default":""},"syntax":"xwiki/2.1","language":"","majorVersion":1,"minorVersion":1,"hidden":false,"enforceRequiredRights":false,"created":1781247689000,"creator":"XWiki.admin","creatorName":null,"modified":1781247689000,"modifier":"XWiki.admin","modifierName":null,"originalMetadataAuthor":"xwiki:XWiki.admin","originalMetadataAuthorName":null,"comment":"","content":"","clazz":null,"objects":null,"attachments":null,"hierarchy":{"items":[{"label":"xwiki","name":"xwiki","type":"wiki","url":"https://xwiki.local/bin/view/Main/"},{"label":"Test
Page for RSpec","name":"Test Page for RSpec","type":"space","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/"},{"label":"A
page automatically created during a create_page test","name":"A page automatically
created during a create_page test","type":"space","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/"},{"label":"WebHome","name":"WebHome","type":"document","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/"}]},"rights":[],"renderedContent":null}'
recorded_at: Fri, 12 Jun 2026 07:01:29 GMT
- request:
method: get
uri: https://xwiki.local/rest/openproject/documents/91d8c
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.6.0 HTTPX Client
Accept:
- application/json
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 200
message: OK
headers:
Content-Language:
- en
Content-Script-Type:
- text/javascript
Content-Type:
- application/json;charset=UTF-8
Date:
- Fri, 12 Jun 2026 07:01:29 GMT
Set-Cookie:
- JSESSIONID=D0A3382830FA89629B3D86592A6FF515; Path=/; HttpOnly
Xwiki-Form-Token:
- Lgex3rjqeBxHH0hyu8cGBQ
Xwiki-User:
- xwiki:XWiki.admin
Xwiki-Version:
- 18.3.0
Content-Length:
- '3259'
body:
encoding: UTF-8
string: '{"links":[{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/spaces/A%20page%20automatically%20created%20during%20a%20create_page%20test","rel":"http://www.xwiki.org/rel/space","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/spaces/A%20page%20automatically%20created%20during%20a%20create_page%20test/pages/WebHome/history","rel":"http://www.xwiki.org/rel/history","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/spaces/Test%20Page%20for%20RSpec/spaces/A%20page%20automatically%20created%20during%20a%20create_page%20test/pages/WebHome/objects","rel":"http://www.xwiki.org/rel/objects","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/syntaxes","rel":"http://www.xwiki.org/rel/syntaxes","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/openproject/documents/91d8c","rel":"self","type":null,"hrefLang":null},{"href":"https://xwiki.local/rest/wikis/xwiki/classes/Test%20Page%20for%20RSpec.A%20page%20automatically%20created%20during%20a%20create_page%20test.WebHome","rel":"http://www.xwiki.org/rel/class","type":null,"hrefLang":null}],"id":"91d8c","fullName":"Test
Page for RSpec.A page automatically created during a create_page test.WebHome","wiki":"xwiki","space":"Test
Page for RSpec.A page automatically created during a create_page test","name":"WebHome","title":"A
page automatically created during a create_page test","rawTitle":"A page automatically
created during a create_page test","parent":"","parentId":"","version":"1.1","author":"XWiki.admin","authorName":null,"xwikiRelativeUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/","xwikiAbsoluteUrl":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/","translations":{"links":[],"translations":[],"default":""},"syntax":"xwiki/2.1","language":"","majorVersion":1,"minorVersion":1,"hidden":false,"enforceRequiredRights":false,"created":1781247689000,"creator":"XWiki.admin","creatorName":null,"modified":1781247689000,"modifier":"XWiki.admin","modifierName":null,"originalMetadataAuthor":"xwiki:XWiki.admin","originalMetadataAuthorName":null,"comment":"Created
URL Shortener.","content":"","clazz":null,"objects":null,"attachments":null,"hierarchy":{"items":[{"label":"xwiki","name":"xwiki","type":"wiki","url":"https://xwiki.local/bin/view/Main/"},{"label":"Test
Page for RSpec","name":"Test Page for RSpec","type":"space","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/"},{"label":"A
page automatically created during a create_page test","name":"A page automatically
created during a create_page test","type":"space","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/"},{"label":"WebHome","name":"WebHome","type":"document","url":"https://xwiki.local/bin/view/Test%20Page%20for%20RSpec/A%20page%20automatically%20created%20during%20a%20create_page%20test/"}]},"rights":[],"renderedContent":null}'
recorded_at: Fri, 12 Jun 2026 07:01:29 GMT
recorded_with: VCR 6.4.0
@@ -0,0 +1,52 @@
---
http_interactions:
- request:
method: get
uri: https://xwiki.local/rest/openproject/documents/abc123
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.6.0 HTTPX Client
Accept:
- application/json
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 404
message: Not Found
headers:
Content-Language:
- en
Content-Script-Type:
- text/javascript
Content-Type:
- text/html;charset=utf-8
Date:
- Thu, 11 Jun 2026 14:40:01 GMT
Set-Cookie:
- JSESSIONID=D9B95B99F5C18BBBF0957992EE0CBD09; Path=/; HttpOnly
Xwiki-Form-Token:
- 1FHSxxmCi5SpcFuCzBbysA
Xwiki-User:
- xwiki:XWiki.admin
Xwiki-Version:
- 18.3.0
Content-Length:
- '714'
body:
encoding: UTF-8
string: <!doctype html><html lang="en"><head><title>HTTP Status 404 Not Found</title><style
type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b
{color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;}
h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP
Status 404 Not Found</h1><hr class="line" /><p><b>Type</b> Status Report</p><p><b>Message</b>
Not Found</p><p><b>Description</b> The origin server did not find a current
representation for the target resource or is not willing to disclose that
one exists.</p><hr class="line" /><h3>Apache Tomcat/10.1.55</h3></body></html>
recorded_at: Thu, 11 Jun 2026 14:40:01 GMT
recorded_with: VCR 6.4.0