Add more specs

This commit is contained in:
Oliver Günther
2025-07-04 22:09:58 +02:00
parent 0e2142640a
commit 04dca13ab5
15 changed files with 882 additions and 35 deletions
+34
View File
@@ -0,0 +1,34 @@
# 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 IncomingEmails
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
end
@@ -28,10 +28,6 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module IncomingEmails
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
class DispatchService
include ActionView::Helpers::SanitizeHelper
@@ -54,6 +50,10 @@ module IncomingEmails
handlers.unshift(handler_class)
end
def self.remove_handler(handler_class)
handlers.delete(handler_class)
end
attr_reader :email, :sender_email, :user, :options, :success, :logs
def initialize(email, options:)
@@ -94,6 +94,7 @@ module IncomingEmails
# the subject. Additionally, the subject structure might change, e.g. via localization changes.
def dispatch
call = call_matching_handler
return if call.nil?
@success = call.success?
log_handler_call(call)
@@ -101,13 +102,10 @@ module IncomingEmails
call.result
rescue ActiveRecord::RecordInvalid => e
log "could not save record: #{e.message}", :error
nil
rescue MissingInformation => e
log "missing information from #{user}: #{e.message}", :error
nil
rescue UnauthorizedAction
log "unauthorized attempt from #{user}", :error
nil
end
def log_handler_call(call)
@@ -127,7 +125,7 @@ module IncomingEmails
:info,
report: false
false
nil
end
end
@@ -262,10 +260,10 @@ module IncomingEmails
message = "MailHandler: #{message}"
Rails.logger.public_send(level, message)
nil
end
def assign_options(value)
# rubocop:disable Metrics/AbcSize
def assign_options(value) # rubocop:disable Metrics/AbcSize
options = value.dup
options[:issue] ||= {}
@@ -280,6 +278,7 @@ module IncomingEmails
options[:allow_override] << :type unless options[:issue].has_key?(:type)
# Priority overridable by default
options[:allow_override] << :priority unless options[:issue].has_key?(:priority)
options[:no_permission_check] = ActiveRecord::Type::Boolean.new.cast(options[:no_permission_check])
options
@@ -49,6 +49,10 @@ module IncomingEmails::Handlers
raise NotImplementedError, "Subclasses must implement handle method"
end
def cleaned_up_text_body
cleanup_body(plain_text_body)
end
protected
# The receive_* methods have been moved to specific handler classes:
@@ -188,7 +192,7 @@ module IncomingEmails::Handlers
# * parse the email To field
# * specific project (eg. Setting.mail_handler_target_project)
target = Project.find_by(identifier: get_keyword(:project))
raise MissingInformation.new("Unable to determine target project") if target.nil?
raise IncomingEmails::MissingInformation.new("Unable to determine target project") if target.nil?
target
end
@@ -209,10 +213,6 @@ module IncomingEmails::Handlers
end
end
def cleaned_up_text_body
cleanup_body(plain_text_body)
end
# Removes the email body of text after the truncation configurations.
def cleanup_body(body)
delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).compact_blank.map { |s| Regexp.escape(s) }
@@ -42,7 +42,7 @@ module IncomingEmails::Handlers
private
# Receives a reply to a forum message
def receive_message_reply(message_id) # rubocop:disable Metrics/AbcSize
def receive_message_reply(message_id)
message = Message.find_by(id: message_id)
if message
message = message.root
@@ -55,17 +55,22 @@ module IncomingEmails::Handlers
ServiceResult.failure(message: "ignoring reply from [#{sender_email}] to a locked topic",
mesage_type: :warn)
else
reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip,
content: cleaned_up_text_body)
reply.author = user
reply.forum = message.forum
message.children << reply
add_attachments(reply)
reply
ServiceResult.success(result: reply,
message: "Reply added to message ##{message.id}")
create_reply_message(message)
end
end
end
def create_reply_message(root)
reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip,
content: cleaned_up_text_body)
reply.author = user
reply.forum = root.forum
root.children << reply
add_attachments(reply)
reply
ServiceResult.success(result: reply,
message: "Reply added to message ##{root.id}")
end
end
end
@@ -73,17 +73,18 @@ module IncomingEmails::Handlers
call = create_work_package(project)
call.message = if call.success?
"work_package created by #{user}"
else
"work_package could not be created by #{user} due to ##{call.errors.full_messages}"
end
call.message =
if call.success?
"work_package created by #{user}"
else
"work_package could not be created by #{user} due to ##{call.errors.full_messages}"
end
call
end
# Adds a note to an existing work package
def receive_work_package_reply(work_package_id)
def receive_work_package_reply(work_package_id) # rubocop:disable Metrics/AbcSize
work_package = ::WorkPackage.find_by(id: work_package_id)
return unless work_package
+2 -1
View File
@@ -33,7 +33,8 @@ module IncomingEmails
##
# Code copied from base class and extended with optional options parameter
# as well as force_encoding support.
def self.receive(raw_mail, options = {})
def self.receive(input, options = {})
raw_mail = input.dup
raw_mail.force_encoding("ASCII-8BIT") if raw_mail.respond_to?(:force_encoding)
ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
@@ -1,3 +1,5 @@
# frozen_string_literal: true
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
+3 -1
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -31,7 +33,7 @@ require "net/pop"
module Redmine
module POP3
class << self
def check(pop_options = {}, options = {})
def check(pop_options = {}, options = {}) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
host = pop_options[:host] || "127.0.0.1"
port = pop_options[:port] || "110"
apop = (pop_options[:apop].to_s == "1")
+1 -1
View File
@@ -30,7 +30,7 @@
require "spec_helper"
RSpec.describe IncomingEmails::MailHandler do
RSpec.describe IncomingEmails::MailHandler do # rubocop:disable RSpec/SpecFilePathFormat
# we need these run first so the anonymous and system users are created and
# there is a default work package priority to save any work packages
shared_let(:anno_user) { User.anonymous }
@@ -0,0 +1,226 @@
# 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"
RSpec.describe IncomingEmails::DispatchService do
let(:email) { instance_double(Mail::Message) }
let(:text_part) { instance_double(Mail::Part) }
let(:body) { instance_double(Mail::Body) }
let(:options) { {} }
let(:service) { described_class.new(email, options:) }
before do
allow(body).to receive_messages(decoded: "Test body".dup)
allow(text_part).to receive_messages(body:, charset: "UTF-8")
allow(email).to receive_messages(text_part: text_part, from: ["test@example.com"])
end
describe "#initialize" do
it "initializes with email and options" do
expect(service.email).to eq(email)
expect(service.options).to include(options)
end
it "extracts sender email from the email" do
expect(service.sender_email).to eq("test@example.com")
end
it "initializes empty logs array" do
expect(service.logs).to eq([])
end
end
describe ".handlers" do
it "returns the default handlers" do
expect(described_class.handlers).to include(
IncomingEmails::Handlers::MessageReply,
IncomingEmails::Handlers::WorkPackage
)
end
end
describe ".register_handler" do
let(:custom_handler) { class_double(IncomingEmails::Handlers::WorkPackage) }
after do
described_class.remove_handler(custom_handler)
end
it "adds handler to the beginning of the handlers list" do
described_class.register_handler(custom_handler)
expect(described_class.handlers).to include(custom_handler)
end
end
describe "#call!" do
let(:user) { build_stubbed(:user) }
before do
allow(service).to receive(:determine_actor)
allow(service).to receive_messages(ignore_mail?: false, user: user)
allow(service).to receive(:dispatch)
end
it "calls determine_actor" do
service.call!
expect(service).to have_received(:determine_actor)
end
it "calls dispatch when user is present" do
service.call!
expect(service).to have_received(:dispatch)
end
it "does not call dispatch when user is not present" do
allow(service).to receive(:user).and_return(nil)
service.call!
expect(service).not_to have_received(:dispatch)
end
it "returns early when mail should be ignored" do
allow(service).to receive(:ignore_mail?).and_return(true)
service.call!
expect(service).not_to have_received(:determine_actor)
end
end
describe "#ignore_mail?" do
it "returns false by default" do
allow(service).to receive_messages(mail_from_system?: false, ignored_by_header?: false, ignored_user?: false)
expect(service.send(:ignore_mail?)).to be_falsey
end
end
describe "#mail_from_system?" do
context "when email is from system address" do
before do
allow(Setting).to receive(:mail_from).and_return("system@example.com")
allow(email).to receive(:from).and_return(["system@example.com"])
end
it "returns true" do
service = described_class.new(email, options:)
expect(service.send(:mail_from_system?)).to be_truthy
end
end
context "when email is not from system address" do
before do
allow(Setting).to receive(:mail_from).and_return("system@example.com")
allow(email).to receive(:from).and_return(["user@example.com"])
end
it "returns false" do
service = described_class.new(email, options:)
expect(service.send(:mail_from_system?)).to be_falsey
end
end
end
describe "#ignored_by_header?" do
context "with auto-response suppress header" do
before do
allow(email).to receive(:header).and_return({ "X-Auto-Response-Suppress" => "OOF" })
end
it "returns true" do
expect(service.send(:ignored_by_header?)).to be_truthy
end
end
context "with auto-submitted header" do
before do
allow(email).to receive(:header).and_return({ "Auto-Submitted" => "auto-replied" })
end
it "returns true" do
expect(service.send(:ignored_by_header?)).to be_truthy
end
end
context "without ignored headers" do
before do
allow(email).to receive(:header).and_return({})
end
it "returns false" do
expect(service.send(:ignored_by_header?)).to be_falsey
end
end
end
describe "#object_reference_from_header" do
context "with valid reference header" do
before do
allow(email).to receive(:references).and_return(["<op.work_packages-123@example.com>"])
end
it "extracts object reference" do
reference = service.send(:object_reference_from_header)
expect(reference).to eq({ klass: "work_packages", id: 123 })
end
end
context "without valid reference header" do
before do
allow(email).to receive(:references).and_return([])
end
it "returns empty hash" do
reference = service.send(:object_reference_from_header)
expect(reference).to eq({})
end
end
end
describe "#instantiate_matching_handler" do
let(:handler_class) { class_double(IncomingEmails::Handlers::WorkPackage) }
let(:handler_instance) { instance_double(IncomingEmails::Handlers::WorkPackage) }
before do
allow(service).to receive(:object_reference_from_header).and_return({})
allow(described_class).to receive(:handlers).and_return([handler_class])
allow(handler_class).to receive_messages(handles?: true, new: handler_instance)
end
it "finds and instantiates matching handler" do
result = service.send(:instantiate_matching_handler)
expect(result).to eq(handler_instance)
end
it "returns nil when no handler matches" do
allow(handler_class).to receive(:handles?).and_return(false)
result = service.send(:instantiate_matching_handler)
expect(result).to be_nil
end
end
end
@@ -55,5 +55,104 @@ RSpec.describe IncomingEmails::Handlers::Base do
expect(subject.cleaned_up_text_body).to eq("Subject:foo\nDescription:bar")
end
end
context "with string delimiters" do
before do
allow(Setting).to receive_messages(mail_handler_body_delimiters: "---", mail_handler_body_delimiter_regex: "")
end
it "removes content after delimiter" do
body = "Before delimiter\n---\nAfter delimiter"
result = subject.send(:cleanup_body, body)
expect(result).to eq("Before delimiter")
end
end
end
describe "#get_keyword" do
let(:plain_text_body) { "Project: test\nStatus: Open\nSome content here".dup }
let(:options) { { allow_override: [:project], issue: {} } }
it "extracts keyword from body" do
result = subject.send(:get_keyword, :project)
expect(result).to eq("test")
end
it "returns nil for non-existent keyword" do
result = subject.send(:get_keyword, :nonexistent)
expect(result).to be_nil
end
it "respects allow_override option" do
result = subject.send(:get_keyword, :status)
expect(result).to be_nil # status not in allow_override
end
end
describe "#extract_keyword!" do
it "extracts and removes keyword from text" do
text = "Project: test\nStatus: Open\nSome content here".dup
result = subject.send(:extract_keyword!, text, :project, nil)
expect(result).to eq("test")
expect(text).not_to include("Project: test")
end
it "handles case insensitive matching" do
text = "PROJECT: test\nContent".dup
result = subject.send(:extract_keyword!, text, :project, nil)
expect(result).to eq("test")
end
end
describe "#human_attr_translations" do
let(:user) { build_stubbed(:user, language: "en") }
it "returns array of attribute translations" do
result = subject.send(:human_attr_translations, :project)
expect(result).to include("project", "Project")
end
it "includes translations for user language" do
allow(Setting).to receive(:default_language).and_return("en")
result = subject.send(:human_attr_translations, :project)
expect(result).to be_an(Array)
expect(result).not_to be_empty
end
end
describe "#ignored_filename?" do
before do
allow(Setting).to receive(:mail_handler_ignore_filenames).and_return("signature.asc\n*.tmp")
end
it "returns true for ignored filenames" do
expect(subject.send(:ignored_filename?, "signature.asc")).to be_truthy
end
it "returns false for non-ignored filenames" do
expect(subject.send(:ignored_filename?, "document.pdf")).to be_falsey
end
end
describe "#lookup_case_insensitive_key" do
let(:scope) { class_double(Status) }
let(:status) { build_stubbed(:status) }
let(:options) { { allow_override: [:status], issue: {} } }
let(:plain_text_body) { "Status: resolved".dup }
before do
allow(scope).to receive(:find_by).with("lower(name) = ?", "resolved").and_return(status)
end
it "finds record case insensitively" do
result = subject.send(:lookup_case_insensitive_key, scope, :status)
expect(result).to eq(status.id)
end
it "returns nil when keyword not found" do
allow(scope).to receive(:find_by).and_return(nil)
result = subject.send(:lookup_case_insensitive_key, scope, :status)
expect(result).to be_nil
end
end
end
@@ -0,0 +1,113 @@
# 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"
RSpec.describe IncomingEmails::Handlers::MessageReply do
let(:email) { instance_double(Mail::Message, attachments: [], subject: "Email subject") }
let(:user) { build_stubbed(:user) }
let(:reference) { {} }
let(:options) { {} }
let(:plain_text_body) { "Test body" }
subject(:handler) do
described_class.new(email, user:, reference:, plain_text_body:, options:)
end
describe ".handles?" do
context "with message reference" do
let(:reference) { { klass: "message", id: 123 } }
it "returns true" do
expect(described_class).to be_handles(email, reference:)
end
end
context "without message reference" do
let(:reference) { {} }
it "returns false" do
expect(described_class).not_to be_handles(email, reference:)
end
end
context "with work package reference" do
let(:reference) { { klass: "work_packages", id: 123 } }
it "returns false" do
expect(described_class).not_to be_handles(email, reference:)
end
end
end
describe "#process" do
let(:project) { create(:project) }
let(:forum) { create(:forum, project:) }
let(:message) { create(:message, forum:) }
let(:reference) { { klass: "messages", id: message.id } }
before do
allow(Message).to receive(:find_by).with(id: message.id).and_return(message)
end
context "when not allowed to create a message" do
it "raises an exception" do
expect { handler.process }.to raise_error(IncomingEmails::UnauthorizedAction)
end
end
context "when allowed to create a message" do
before do
mock_permissions_for(user) do |mock|
mock.allow_in_project(:add_messages, project:)
end
end
it "creates a reply message" do
call = handler.process
expect(call).to be_a(ServiceResult)
message = call.result
expect(message).to be_a(Message)
end
end
context "when parent message is not found" do
before do
allow(Message).to receive(:find_by).and_return(nil)
end
it "returns nil" do
result = handler.process
expect(result).to be_nil
end
end
end
end
@@ -0,0 +1,98 @@
# 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"
RSpec.describe IncomingEmails::Handlers::WorkPackage do
let(:email) { instance_double(Mail::Message, attachments: [], subject: "My message") }
let(:user) { build_stubbed(:user) }
let(:reference) { {} }
let(:options) { { issue: { project: "foobar" }, allow_override: [] } }
let(:plain_text_body) { "Test body".dup }
subject(:handler) do
described_class.new(email, user:, reference:, plain_text_body:, options:)
end
describe ".handles?" do
context "with work package reference" do
let(:reference) { { klass: "work_package", id: 123 } }
it "returns true" do
expect(described_class).to be_handles(email, reference:)
end
end
context "without work package reference" do
let(:reference) { {} }
it "returns true for new work package creation" do
expect(described_class).to be_handles(email, reference:)
end
end
context "with message reference" do
let(:reference) { { klass: "messages", id: 123 } }
it "returns false" do
expect(described_class).not_to be_handles(email, reference:)
end
end
end
describe "#process" do
let(:service_result) { ServiceResult.success(result: build_stubbed(:work_package)) }
let(:service_instance) { instance_double(WorkPackages::CreateService, call: service_result) }
let(:project) { build_stubbed(:project) }
before do
allow(WorkPackages::CreateService).to receive(:new).and_return(service_instance)
allow(Project).to receive(:find_by).with(identifier: "foobar").and_return(project)
end
it "creates a work package" do
result = handler.process
expect(WorkPackages::CreateService).to have_received(:new)
expect(result).to be_a(ServiceResult)
end
context "when work package creation fails" do
let(:errors) { ActiveModel::Errors.new(nil) }
let(:service_result) { ServiceResult.failure(errors:) }
it "returns the failed result" do
result = handler.process
expect(result).to be_a(ServiceResult)
expect(result).not_to be_success
expect(result.errors).to eq(errors)
end
end
end
end
@@ -0,0 +1,267 @@
# 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"
RSpec.describe IncomingEmails::MailHandler, "integration", type: :service do
# Integration tests to ensure backwards compatibility after refactoring
shared_let(:project) { create(:valid_project, identifier: "test-project") }
shared_let(:user) { create(:user, mail: "test@example.com") }
shared_let(:priority) { create(:priority_low, is_default: true) }
describe "MailHandler.receive" do
let(:raw_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: Test Work Package
Content-Type: text/plain
Project: #{project.identifier}
This is a test work package created via email.
EMAIL
end
it "still processes emails through the old API" do
result = described_class.receive(raw_email)
expect(result).to be_a(WorkPackage)
expect(result.subject).to eq("Test Work Package")
expect(result.project).to eq(project)
expect(result.author).to eq(user)
expect(result.description).to include("This is a test work package created via email.")
end
it "handles options parameter" do
result = described_class.receive(raw_email, { allow_override: "priority" })
expect(result).to be_a(WorkPackage)
expect(result.project).to eq(project)
end
context "with unknown user" do
let(:unknown_email) do
<<~EMAIL
From: unknown@example.com
To: project@example.com
Subject: Test from unknown
Content-Type: text/plain
Project: #{project.identifier}
This is from an unknown user.
EMAIL
end
it "handles unknown_user option, but does not skip permission checks" do
result = described_class.receive(unknown_email, { unknown_user: "accept" })
expect(result).to be_a(WorkPackage)
expect(result.project).to eq(project)
expect(result.errors.symbols_for(:base)).to contain_exactly(:error_unauthorized)
end
end
context "with auto-reply headers" do
let(:auto_reply_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: Test Auto Reply
X-Auto-Response-Suppress: OOF
Content-Type: text/plain
Project: #{project.identifier}
This is an auto-reply.
EMAIL
end
it "ignores auto-reply emails" do
result = described_class.receive(auto_reply_email)
expect(result).to be_nil
end
end
context "with work package reference" do
let!(:work_package) { create(:work_package, project:) }
let(:options) { {} }
let(:reply_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: Re: #{work_package.subject}
References: <op.work_package-#{work_package.id}@example.com>
Content-Type: text/plain
This is a reply to the work package.
EMAIL
end
subject { described_class.receive(reply_email, options) }
shared_examples "successful work package reply" do
it "processes work package replies" do
expect { subject }.to change(Journal, :count).by(1)
expect(subject).to be_a(Journal)
expect(subject.journable).to eq(work_package)
expect(subject.notes).to include("This is a reply to the work package.")
end
end
context "with permission" do
let!(:role) { create(:project_role, permissions: %i[view_work_packages add_work_package_comments]) }
let!(:member) { create(:member, project:, user:, roles: [role]) }
it_behaves_like "successful work package reply"
end
context "without permission" do
it "fails to process" do
expect { subject }.not_to change(Journal, :count)
end
end
context "without permission, but no_permission_check set" do
let(:options) do
{ no_permission_check: true }
end
it_behaves_like "successful work package reply"
end
end
context "with message reference" do
let(:options) { {} }
let!(:message) { create(:message, forum: create(:forum, project:)) }
let(:message_reply_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: Re: #{message.subject}
References: <op.message-#{message.id}@example.com>
Content-Type: text/plain
This is a reply to the message.
EMAIL
end
subject { described_class.receive(message_reply_email, options) }
shared_examples "successful message reply" do
it "processes message replies" do
expect { subject }.to change(Message, :count).by(1)
expect(subject).to be_a(Message)
expect(subject.parent).to eq(message)
expect(subject.content).to include("This is a reply to the message.")
end
end
context "with permission" do
let!(:role) { create(:project_role, permissions: [:add_messages]) }
let!(:member) { create(:member, project:, user:, roles: [role]) }
it_behaves_like "successful message reply"
end
context "without permission" do
it "fails to process" do
expect { subject }.not_to change(Message, :count)
expect(subject).to be_nil
end
end
context "without permission, but no_permission_check set" do
let(:options) do
{ no_permission_check: true }
end
it_behaves_like "successful message reply"
end
end
end
describe "Error handling" do
let(:invalid_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: Invalid Email
Content-Type: text/plain
Project: nonexistent-project
This references a nonexistent project.
EMAIL
end
it "handles errors gracefully" do
expect { described_class.receive(invalid_email) }.not_to raise_error
end
end
describe "Handler registration" do
let(:custom_handler) do
Class.new(IncomingEmails::Handlers::Base) do
def self.handles?(email, **)
email.subject&.include?("CUSTOM")
end
def process
ServiceResult.success(message: "Custom handler processed", result: "foo result")
end
end
end
let(:custom_email) do
<<~EMAIL
From: test@example.com
To: project@example.com
Subject: CUSTOM Test Email
Content-Type: text/plain
This should be handled by the custom handler.
EMAIL
end
it "allows custom handlers to be registered" do
IncomingEmails::DispatchService.register_handler(custom_handler)
result = described_class.receive(custom_email.dup)
expect(result).to eq("foo result")
end
end
end
@@ -28,7 +28,7 @@
require "spec_helper"
RSpec.describe MailHandler::UserCreator do
RSpec.describe IncomingEmails::UserCreator do
describe ".new_user_from_attributes" do
context "with sufficient information" do
# [address, name] => [login, firstname, lastname]