diff --git a/service/lib/agama/storage/proposal_settings_conversion/from_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversion/from_y2storage.rb index 8ba01f4cca..5152d4e8d7 100644 --- a/service/lib/agama/storage/proposal_settings_conversion/from_y2storage.rb +++ b/service/lib/agama/storage/proposal_settings_conversion/from_y2storage.rb @@ -39,7 +39,7 @@ def initialize(settings, config:) # @return [Agama::Storage::ProposalSettings] def convert ProposalSettings.new.tap do |target| - boot_devices_conversion(target) + boot_device_conversion(target) lvm_conversion(target) encryption_conversion(target) space_policy_conversion(target) @@ -101,14 +101,14 @@ def fallbacks_conversion(target) # @param mount_path [String] # @return [Array] def volumes_with_min_size_fallback(mount_path) - specs = settings.volumes.select { |s| s.min_size_fallback == mount_path } + specs = settings.volumes.select { |s| s.fallback_for_min_size == mount_path } specs.map(&:mount_point) end # @param mount_path [String] # @return [Array] def volumes_with_max_size_fallback(mount_path) - specs = settings.volumes.select { |s| s.max_size_fallback == mount_path } + specs = settings.volumes.select { |s| s.fallback_for_max_size == mount_path } specs.map(&:mount_point) end end diff --git a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb index aed1208de4..9ff4c97080 100644 --- a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb +++ b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb @@ -102,8 +102,8 @@ def volumes_conversion(target) # @param target [Y2Storage::ProposalSettings] def fallbacks_conversion(target) target.volumes.each do |spec| - spec.min_size_fallback = find_min_size_fallback(spec.mount_point) - spec.max_size_fallback = find_max_size_fallback(spec.mount_point) + spec.fallback_for_min_size = find_min_size_fallback(spec.mount_point) + spec.fallback_for_max_size = find_max_size_fallback(spec.mount_point) end end @@ -115,7 +115,7 @@ def find_min_size_fallback(mount_path) # @param mount_path [String] def find_max_size_fallback(mount_path) - volume = volumes.find { |v| v.max_size_fallback_for.include?(mount_path) } + volume = settings.volumes.find { |v| v.max_size_fallback_for.include?(mount_path) } volume&.mount_path end @@ -136,7 +136,7 @@ def candidate_devices # @return [Array] def all_devices devices = candidate_devices - devices += settings.volumes.map(&:device) + devices += settings.volumes.map(&:device).compact devices.uniq.map { |d| device_or_partitions(d) }.flatten end diff --git a/service/lib/agama/storage/proposal_settings_reader.rb b/service/lib/agama/storage/proposal_settings_reader.rb index 6757bf2492..6d1cd79e2d 100644 --- a/service/lib/agama/storage/proposal_settings_reader.rb +++ b/service/lib/agama/storage/proposal_settings_reader.rb @@ -37,7 +37,7 @@ def initialize(config) # @return [ProposalSettings] def read settings = ProposalSettings.new - config.fetch("storage", {}).each do |key, value| + config.data.fetch("storage", {}).each do |key, value| send(READERS[key], settings, value) end end diff --git a/service/lib/agama/storage/volume.rb b/service/lib/agama/storage/volume.rb index 23ae79ccf7..40d9bcd477 100644 --- a/service/lib/agama/storage/volume.rb +++ b/service/lib/agama/storage/volume.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "pathname" +require "forwardable" require "agama/storage/btrfs_settings" require "agama/storage/volume_outline" @@ -30,6 +30,8 @@ module Storage # A volume is converted to D-Bus and to Y2Storage formats in order to provide the volume # information with the expected representation, see {VolumeConversion}. class Volume + extend Forwardable + # Mount path # # @return [String] @@ -38,7 +40,7 @@ class Volume # Outline of the volume # # @return [VolumeOutline] - attr_reader :outline + attr_accessor :outline # Filesystem for the volume # @@ -88,6 +90,10 @@ def initialize(mount_path) @outline = VolumeOutline.new end + def_delegators :outline, + :min_size_fallback_for, :min_size_fallback_for=, + :max_size_fallback_for, :max_size_fallback_for= + # Whether it makes sense to have automatic size limits for the volume # # @return [Boolean] diff --git a/service/lib/agama/storage/volume_conversion/to_y2storage.rb b/service/lib/agama/storage/volume_conversion/to_y2storage.rb index e6a2302271..c235111c1e 100644 --- a/service/lib/agama/storage/volume_conversion/to_y2storage.rb +++ b/service/lib/agama/storage/volume_conversion/to_y2storage.rb @@ -40,7 +40,7 @@ def convert # rubocop:disable Metrics/AbcSize target.device = volume.device target.separate_vg_name = volume.separate_vg_name target.mount_point = volume.mount_path - target.mount_options = volume.mount_options + target.mount_options = volume.mount_options.join(",") target.proposed = true target.proposed_configurable = !volume.outline.required? target.fs_types = volume.outline.filesystems diff --git a/service/lib/agama/storage/volume_outline.rb b/service/lib/agama/storage/volume_outline.rb index 0fa9184946..8f6b9bfe76 100644 --- a/service/lib/agama/storage/volume_outline.rb +++ b/service/lib/agama/storage/volume_outline.rb @@ -31,27 +31,27 @@ class VolumeOutline # this volume or an equivalent one (ie. one with the same mount_path). # # @return [Boolean] - attr_reader :required + attr_accessor :required alias_method :required?, :required # Possible filesystem types for the volume # # @return [Array] - attr_reader :filesystems + attr_accessor :filesystems # Base value to calculate the min size for the volume (if #auto_size is set to true # for that final volume) or to use as default value (if #auto_size is false) # # @return [Y2Storage::DiskSize] - attr_reader :base_min_size + attr_accessor :base_min_size # Base value to calculate the max size for the volume (if #auto_size is set to true # for that final volume) or to use as default value (if #auto_size is false) # # @return [Y2Storage::DiskSize] - attr_reader :base_max_size + attr_accessor :base_max_size - attr_reader :adjust_by_ram + attr_accessor :adjust_by_ram alias_method :adjust_by_ram?, :adjust_by_ram # @return [Array] mount paths of other volumes @@ -63,18 +63,18 @@ class VolumeOutline # Whether snapshots option can be configured # # @return [Boolean] - attr_reader :snapshots_configurable + attr_accessor :snapshots_configurable alias_method :snapshots_configurable?, :snapshots_configurable # Size required for snapshots # # @return [Y2Storage::DiskSize, nil] - attr_reader :snapshots_size + attr_accessor :snapshots_size # Percentage of space required for snapshots # # @return [Integer, nil] - attr_reader :snapshots_percentage + attr_accessor :snapshots_percentage def initialize @filesystems = [] diff --git a/service/lib/agama/storage/volume_templates_builder.rb b/service/lib/agama/storage/volume_templates_builder.rb index f5eb031a24..f6bbad50a0 100644 --- a/service/lib/agama/storage/volume_templates_builder.rb +++ b/service/lib/agama/storage/volume_templates_builder.rb @@ -52,12 +52,12 @@ def initialize(config_data) # @param path [String] # @return [Agama::Storage::Volume] def for(path) - data = @data[cleanpath(path)] || @data[""] + data = @data[cleanpath(path)] || @data[""] || empty_data Volume.new(path).tap do |volume| volume.btrfs = data[:btrfs] volume.outline = data[:outline] - volume.filesystem = data[:filesystem] + volume.fs_type = data[:filesystem] volume.mount_options = data[:mount_options] if data[:auto_size] && volume.auto_size_supported? @@ -79,6 +79,16 @@ def key(data) cleanpath(path) end + # Temporary method to avoid crashes if there is no default template + def empty_data + { + btrfs: BtrfsSettings.new, + outline: VolumeOutline.new, + mount_options: [], + filesystem: Y2Storage::Filesystems::Type::EXT4 + } + end + def values(data) # rubocop:disable Metrics/AbcSize {}.tap do |values| values[:btrfs] = btrfs(data) @@ -91,7 +101,7 @@ def values(data) # rubocop:disable Metrics/AbcSize values[:filesystem] ||= values[:outline].filesystems.first values[:filesystem] ||= Y2Storage::Filesystems::Type::EXT4 - size = outline_data.fetch("size", {}) + size = data.fetch("size", {}) values[:auto_size] = size.fetch("auto", false) values[:min_size] = parse_disksize(size["min"]) values[:max_size] = parse_disksize(size["max"]) @@ -141,7 +151,7 @@ def outline(data) # rubocop:disable Metrics/AbcSize end def assign_snapshots_increment(outline, increment) - return if increment.nil + return if increment.nil? if increment =~ /(\d+)\s*%/ outline.snapshots_percentage = Regexp.last_match(1).to_i diff --git a/service/test/agama/storage/proposal_conversions_test.rb b/service/test/agama/storage/proposal_conversions_test.rb deleted file mode 100644 index 3453126c30..0000000000 --- a/service/test/agama/storage/proposal_conversions_test.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# 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, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../test_helper" -require_relative "storage_helpers" -require "agama/storage/proposal" -require "agama/storage/proposal_settings" -require "agama/config" - -describe "Y2Storage conversions at Agama::Storage::Proposal" do - include Agama::RSpec::StorageHelpers - before { mock_storage(devicegraph: scenario) } - let(:scenario) { "partitioned_md.yml" } - - subject(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } - - let(:logger) { Logger.new($stdout, level: :warn) } - let(:config) { Agama::Config.new(config_data) } - let(:config_data) { {} } - - let(:y2storage_proposal) do - instance_double(Y2Storage::MinGuidedProposal, propose: true, failed?: false) - end - - let(:settings) { Agama::Storage::ProposalSettings.new } - - def expect_space_actions(actions) - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) - space = args[:settings].space_settings - expect(space.strategy).to eq :bigger_resize - expect(space.actions).to eq actions - - y2storage_proposal - end - end - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - - # This is needed. Not filled by default. - settings.boot_device = "/dev/sda" - settings.space.policy = policy - end - - context "when preserving existing partitions" do - let(:policy) { :keep } - - it "runs the Y2Storage proposal with an empty list of actions for :bigger_resize" do - expect_space_actions({}) - proposal.calculate(settings) - end - end - - context "when deleting existing partitions" do - let(:policy) { :delete } - - it "runs the Y2Storage proposal with delete actions for every partition" do - expect_space_actions({ "/dev/sda1" => :force_delete, "/dev/sda2" => :force_delete }) - proposal.calculate(settings) - end - end - - context "when deleting existing partitions" do - let(:policy) { :resize } - - it "runs the Y2Storage proposal with resize actions for every partition" do - expect_space_actions({ "/dev/sda1" => :resize, "/dev/sda2" => :resize }) - proposal.calculate(settings) - end - end - end -end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 709e9d90d1..082b8a5d57 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -27,78 +27,30 @@ describe Agama::Storage::Proposal do include Agama::RSpec::StorageHelpers - before { mock_storage } + before { mock_storage(devicegraph: "partitioned_md.yml") } - subject(:proposal) { described_class.new(logger, config) } + subject(:proposal) { described_class.new(config, logger: logger) } let(:logger) { Logger.new($stdout, level: :warn) } let(:config) { Agama::Config.new(config_data) } - let(:config_data) do - { "storage" => { "volumes" => config_volumes } } - end - - let(:config_volumes) do - [ - { - "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB", - "snapshots" => true, "snapshots_percentage" => "300" - }, - { - "mount_point" => "/two", "fs_type" => "xfs", "min_size" => "5 GiB", - "proposed_configurable" => true, "fallback_for_min_size" => "/" - } - ] - end + let(:config_data) { {} } let(:y2storage_proposal) do - instance_double(Y2Storage::MinGuidedProposal, propose: true, failed?: failed) + instance_double(Y2Storage::MinGuidedProposal, propose: true, failed?: false) end - let(:failed) { false } + let(:settings) { Agama::Storage::ProposalSettings.new } describe "#calculate" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - before { allow(Y2Storage::StorageManager.instance).to receive(:proposal=) } - - RSpec.shared_examples "y2storage proposal with clean-up settings" do - it "runs the Y2Storage proposal with settings that destroy the previous content" do - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings].linux_delete_mode).to eq :all - expect(args[:settings].windows_delete_mode).to eq :all - expect(args[:settings].lvm_vg_reuse).to eq false - y2storage_proposal - end - - proposal.calculate - end - end - - RSpec.shared_examples "y2storage proposal with no candidates" do - it "runs the Y2Storage proposal with no candidate devices" do - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings].candidate_devices).to be_nil - y2storage_proposal - end - - proposal.calculate - end + # This is needed. Not filled by default. + settings.boot_device = "/dev/sda" + settings.space.policy = policy end - RSpec.shared_examples "y2storage proposal from config" do - it "runs the Y2Storage proposal with a set of VolumeSpecification based on the config" do - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - vols = args[:settings].volumes - expect(vols).to all(be_a(Y2Storage::VolumeSpecification)) - expect(vols.map(&:mount_point)).to contain_exactly("/", "/two") - - y2storage_proposal - end - - proposal.calculate - end - - include_examples "y2storage proposal with no candidates" - end + let(:policy) { :delete } it "runs all the callbacks" do callback1 = proc {} @@ -110,77 +62,16 @@ expect(callback1).to receive(:call) expect(callback2).to receive(:call) - proposal.calculate + proposal.calculate(settings) end it "stores the given settings" do + allow(Y2Storage::StorageManager.instance).to receive(:proposal=).and_call_original expect(proposal.settings).to be_nil - settings = Agama::Storage::ProposalSettings.new proposal.calculate(settings) - expect(proposal.settings).to eq(settings) - end - - context "with undefined settings and no storage section in the config" do - let(:config_data) { {} } - - it "runs the Y2Storage proposal with a default set of VolumeSpecification" do - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) - vols = args[:settings].volumes - expect(vols).to_not be_empty - expect(vols).to all(be_a(Y2Storage::VolumeSpecification)) - - y2storage_proposal - end - - proposal.calculate - end - - include_examples "y2storage proposal with no candidates" - include_examples "y2storage proposal with clean-up settings" - end - - context "with undefined settings" do - include_examples "y2storage proposal from config" - include_examples "y2storage proposal with clean-up settings" - end - - context "with the default settings" do - let(:settings) { Agama::Storage::ProposalSettings.new } - - include_examples "y2storage proposal from config" - include_examples "y2storage proposal with clean-up settings" - end - - context "with settings defining a list of candidate devices" do - let(:settings) do - settings = Agama::Storage::ProposalSettings.new - settings.candidate_devices = devices - settings - end - - include_examples "y2storage proposal with clean-up settings" - - context "if the defined list is empty" do - let(:devices) { [] } - - include_examples "y2storage proposal with no candidates" - end - - context "if the defined list contains valid device names" do - let(:devices) { ["/dev/sda"] } - - it "runs the Y2Storage proposal with the specified candidate devices" do - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings].candidate_devices).to eq devices - y2storage_proposal - end - - proposal.calculate(settings) - end - end + expect(proposal.settings).to_not be_nil end context "when the Y2Storage proposal succeeds" do @@ -188,29 +79,31 @@ manager = Y2Storage::StorageManager.instance expect(manager).to receive(:proposal=).and_call_original - expect(proposal.calculate).to eq true + expect(proposal.calculate(settings)).to eq true expect(manager.proposal.failed?).to eq false end end context "when the Y2Storage proposal fails" do - let(:config_volumes) do + before do # Enforce an impossible root of 10 TiB - [{ "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 TiB" }] + root = Agama::Storage::Volume.new("/").tap do |vol| + vol.min_size = Y2Storage::DiskSize.TiB(10) + vol.fs_type = Y2Storage::Filesystems::Type::BTRFS + end + settings.volumes << root end it "returns false and saves the failed proposal" do manager = Y2Storage::StorageManager.instance expect(manager).to receive(:proposal=).and_call_original - expect(proposal.calculate).to eq false + expect(proposal.calculate(settings)).to eq false expect(manager.proposal.failed?).to eq true end end context "with no encryption settings in the config" do - let(:config_data) { {} } - it "runs the Y2Storage proposal with default encryption settings" do expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| expect(args[:settings].encryption_method).to eq Y2Storage::EncryptionMethod::LUKS1 @@ -218,13 +111,14 @@ y2storage_proposal end - proposal.calculate + proposal.calculate(settings) end end context "with encryption settings in the config" do - let(:config_data) do - { "storage" => { "encryption" => { "method" => "luks2", "pbkdf" => "pbkdf2" } } } + before do + settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 + settings.encryption.pbkd_function = Y2Storage::PbkdFunction::PBKDF2 end it "runs the Y2Storage proposal with default encryption settings" do @@ -234,7 +128,45 @@ y2storage_proposal end - proposal.calculate + proposal.calculate(settings) + end + end + + def expect_space_actions(actions) + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| + expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) + space = args[:settings].space_settings + expect(space.strategy).to eq :bigger_resize + expect(space.actions).to eq actions + + y2storage_proposal + end + end + + context "when preserving existing partitions" do + let(:policy) { :keep } + + it "runs the Y2Storage proposal with an empty list of actions for :bigger_resize" do + expect_space_actions({}) + proposal.calculate(settings) + end + end + + context "when deleting existing partitions" do + let(:policy) { :delete } + + it "runs the Y2Storage proposal with delete actions for every partition" do + expect_space_actions({ "/dev/sda1" => :force_delete, "/dev/sda2" => :force_delete }) + proposal.calculate(settings) + end + end + + context "when deleting existing partitions" do + let(:policy) { :resize } + + it "runs the Y2Storage proposal with resize actions for every partition" do + expect_space_actions({ "/dev/sda1" => :resize, "/dev/sda2" => :resize }) + proposal.calculate(settings) end end end @@ -283,111 +215,78 @@ end end - describe "#volume_templates" do - it "returns a list with the default volumes from the configuration" do - templates = proposal.volume_templates - expect(templates).to all be_a(Agama::Storage::Volume) - expect(templates.map(&:mount_point)).to contain_exactly("/", "/two") - end - - context "with no storage section in the configuration" do - let(:config_data) { {} } - - it "returns settings with a fallback list of volumes" do - templates = proposal.volume_templates - expect(templates).to_not be_empty - expect(templates).to all be_a(Agama::Storage::Volume) - end - end - - context "with volumes that are disabled by default" do - let(:config_volumes) do - [ - { "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB" }, - { "mount_point" => "/enabled", "min_size" => "5 GiB" }, - { "mount_point" => "/disabled", "proposed" => false, "min_size" => "5 GiB" } - ] - end - - it "returns a set including enabled and disabled volumes" do - expect(proposal.volume_templates.map(&:mount_point)).to contain_exactly( - "/", "/enabled", "/disabled" - ) - end - end - end - - describe "#calculated_settings" do + describe "#settings" do context "if #calculate has not been called yet" do it "returns nil" do - expect(proposal.calculated_settings).to be_nil + expect(proposal.settings).to be_nil end end - context "if #calculate was called without settings" do + context "if #calculate was called" do before do - proposal.calculate - end - - context "and the config has disabled volumes" do - let(:config_volumes) do - [ - { "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB" }, - { "mount_point" => "/enabled", "min_size" => "5 GiB" }, - { "mount_point" => "/disabled", "proposed" => false, "min_size" => "5 GiB" } - ] + volume = Agama::Storage::Volume.new("/something").tap do |vol| + vol.min_size = Y2Storage::DiskSize.GiB(10) + vol.max_size = Y2Storage::DiskSize.unlimited + vol.fs_type = Y2Storage::Filesystems::Type::EXT2 end + settings.volumes << volume + proposal.calculate(settings) + end - # Note that calling #calculate without settings means "reset to default" - it "returns settings with only the volumes enabled by default" do - expect(proposal.calculated_settings.volumes.map(&:mount_point)) - .to contain_exactly("/", "/enabled") - end + it "returns the settings from the #calculate call" do + expect(proposal.settings.volumes.map(&:mount_path)).to include("/something") end end end describe "#issues" do - before do - allow(subject).to receive(:proposal).and_return(y2storage_proposal) - allow(subject).to receive(:available_devices).and_return(available_devices) - allow(subject).to receive(:candidate_devices).and_return(candidate_devices) - end - - let(:sda) { instance_double(Y2Storage::Disk, name: "/dev/sda") } - let(:available_devices) { [sda] } - let(:candidate_devices) { [] } - context "when the proposal does not exist yet" do - let(:y2storage_proposal) { nil } - it "returns an empty list" do expect(subject.issues).to eq([]) end end - context "when no candidate devices are selected" do - let(:candidate_devices) { [] } + context "when there was already a proposal attempt" do + before do + settings.boot_device = boot_device + proposal.calculate(settings) + end - it "returns a list of errors including the expected error" do - expect(subject.issues).to include( - an_object_having_attributes(description: /No devices are selected/) - ) + let(:sda) { instance_double(Y2Storage::Disk, name: "/dev/sda") } + let(:boot_device) { nil } + + context "but no boot device was selected" do + let(:boot_device) { nil } + + it "returns a list of errors including the expected error" do + expect(subject.issues).to include( + an_object_having_attributes(description: /No device selected/) + ) + end end - end - context "when some candidate device is missing" do - let(:candidate_devices) { ["/dev/vda"] } + context "but the boot device is missing" do + let(:boot_device) { "/dev/vda" } - it "returns a list of errors including the expected error" do - expect(subject.issues).to include( - an_object_having_attributes(description: /devices are not found/) - ) + it "returns a list of errors including the expected error" do + expect(subject.issues).to include( + an_object_having_attributes(description: /device is not found/) + ) + end end end - context "when the proposal failed" do - let(:failed) { true } + context "when there was a failed proposal attempt" do + before do + # Enforce an impossible root of 10 TiB + root = Agama::Storage::Volume.new("/").tap do |vol| + vol.min_size = Y2Storage::DiskSize.TiB(10) + vol.fs_type = Y2Storage::Filesystems::Type::BTRFS + end + settings.volumes << root + settings.boot_device = "/dev/sda" + proposal.calculate(settings) + end it "returns a list of errors including the expected error" do expect(subject.issues).to include(