diff --git a/lib/superset/dashboard/import.rb b/lib/superset/dashboard/import.rb index c298d02..01240ee 100644 --- a/lib/superset/dashboard/import.rb +++ b/lib/superset/dashboard/import.rb @@ -1,29 +1,32 @@ -# Import the provided Dashboard zip file +# frozen_string_literal: true + +# Import the provided Dashboard zip file or directory (aka source) # In the context of this API import process, assumption is that the database.yaml file details will match # an existing database in the Target Superset Environment. # Scenario 1: Export from Env1 -- Import to Env1 into the SAME Environment -# Will result in updating/over writing the dashboard with the contents of the zip file +# Will result in updating/over writing the dashboard with the contents of the source # Scenario 2: Export from Env1 -- Import to Env2 into a DIFFERENT Environment -# Assumption is that the database.yaml will match a database configuration in the target env. -# Initial import will result in creating a new dashboard with the contents of the zip file. -# Subsequent imports will result in updating/over writing the previous imported dashboard with the contents of the zip file. +# Assumption is that the database.yaml will match a database configuration in the target env. +# Initial import will result in creating a new dashboard with the contents of the source. +# Subsequent imports will result in updating/over writing the previous imported dashboard with the contents of the source. # the overwrite flag will determine if the dashboard will be updated or created new # overwrite: false .. will result in an error if a dashboard with the same UUID already exists # Usage -# Superset::Dashboard::Import.new(source_zip_file: '/tmp/dashboard.zip').perform +# Superset::Dashboard::Import.new(source: '/tmp/dashboard.zip').perform +# Superset::Dashboard::Import.new(source: '/tmp/dashboard').perform # module Superset module Dashboard class Import < Request - attr_reader :source_zip_file, :overwrite + attr_reader :source, :overwrite - def initialize(source_zip_file: , overwrite: true) - @source_zip_file = source_zip_file + def initialize(source:, overwrite: true) + @source = source @overwrite = overwrite end @@ -42,16 +45,20 @@ def response private def validate_params - raise ArgumentError, 'source_zip_file is required' if source_zip_file.nil? - raise ArgumentError, 'source_zip_file does not exist' unless File.exist?(source_zip_file) - raise ArgumentError, 'source_zip_file is not a zip file' unless File.extname(source_zip_file) == '.zip' - raise ArgumentError, 'overwrite must be a boolean' unless [true, false].include?(overwrite) - raise ArgumentError, "zip target database does not exist: #{zip_database_config_not_found_in_superset}" if zip_database_config_not_found_in_superset.present? + raise ArgumentError, "source is required" if source.nil? + raise ArgumentError, "source does not exist" unless File.exist?(source) + raise ArgumentError, "source is not a zip file or directory" unless zip? || directory? + raise ArgumentError, "overwrite must be a boolean" unless [true, false].include?(overwrite) + + return unless database_config_not_found_in_superset.present? + + raise ArgumentError, + "target database does not exist: #{database_config_not_found_in_superset}" end def payload { - formData: Faraday::UploadIO.new(source_zip_file, 'application/zip'), + formData: Faraday::UploadIO.new(source_zip_file, "application/zip"), overwrite: overwrite.to_s } end @@ -60,24 +67,56 @@ def route "dashboard/import/" end - def zip_database_config_not_found_in_superset - zip_databases_details.select {|s| !superset_database_uuids_found.include?(s[:uuid]) } + def zip? + File.extname(source) == ".zip" end - def superset_database_uuids_found - @superset_database_uuids_found ||= begin - zip_databases_details.map {|i| i[:uuid]}.map do |uuid| - uuid if Superset::Database::List.new(uuid_equals: uuid).result.present? - end.compact + def directory? + File.directory?(source) + end + + def source_zip_file + return source if zip? + + Zip::File.open(new_zip_file, Zip::File::CREATE) do |zipfile| + Dir[File.join(source, "**", "**")].each do |file| + zipfile.add(file.sub("#{source}/", "#{File.basename(source)}/"), file) if File.file?(file) + end end + new_zip_file + end + + def new_zip_file + new_database_name = dashboard_config[:databases].first[:content][:database_name] + File.join(source, "dashboard_import.zip") end - def zip_databases_details - zip_dashboard_config[:databases].map{|d| {uuid: d[:content][:uuid], name: d[:content][:database_name]} } + def database_config_not_found_in_superset + databases_details.reject { |s| superset_database_uuids_found.include?(s[:uuid]) } + end + + def superset_database_uuids_found + @superset_database_uuids_found ||= databases_details.map { |i| i[:uuid] }.map do |uuid| + uuid if Superset::Database::List.new(uuid_equals: uuid).result.present? + end.compact + end + + def databases_details + dashboard_config[:databases].map { |d| { uuid: d[:content][:uuid], name: d[:content][:database_name] } } + end + + def dashboard_config + @dashboard_config ||= zip? ? zip_dashboard_config : directory_dashboard_config end def zip_dashboard_config - @zip_dashboard_config ||= Superset::Services::DashboardLoader.new(dashboard_export_zip: source_zip_file).perform + Superset::Services::DashboardLoader.new(dashboard_export_zip: source).perform + end + + def directory_dashboard_config + Superset::Services::DashboardLoader::DashboardConfig.new( + dashboard_export_zip: "", tmp_uniq_dashboard_path: source + ).config end end end diff --git a/lib/superset/database/import.rb b/lib/superset/database/import.rb new file mode 100644 index 0000000..10db8fd --- /dev/null +++ b/lib/superset/database/import.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Import the provided Database zip file or directory (aka source) + +# Scenario 1: Export from Env1 -- Import to Env1 into the SAME Environment +# Will result in updating/over writing the database with the contents of the source + +# Scenario 2: Export from Env1 -- Import to Env2 into a DIFFERENT Environment +# Initial import will result in creating a new database with the contents of the source. +# Subsequent imports will result in updating/over writing the previous imported database with the contents of the source. + +# the overwrite flag will determine if the database will be updated or created new +# overwrite: false .. will result in an error if a database with the same UUID already exists + +# passwords can be set by passing in an hash in the form {"databases/MyDatabase.yaml": "my_password", "databases/db2.yaml": "other_pass"} +# Usage +# Superset::Database::Import.new(source: '/tmp/database.zip').perform +# Superset::Database::Import.new(source: '/tmp/database').perform +# + +require "json" +require "zip" +require "superset/file_utilities" + +module Superset + module Database + class Import < Request + include FileUtilities + + attr_reader :source, :overwrite, :passwords, :ssh_tunnel_passwords, + :ssh_tunnel_private_key_passwords, :ssh_tunnel_private_keys + + def initialize(source:, overwrite: true, passwords: {}, + ssh_tunnel_passwords: {}, ssh_tunnel_private_key_passwords: {}, + ssh_tunnel_private_keys: {}) + @source = source + @overwrite = overwrite + @passwords = passwords + @ssh_tunnel_passwords = ssh_tunnel_passwords + @ssh_tunnel_private_key_passwords = ssh_tunnel_private_key_passwords + @ssh_tunnel_private_keys = ssh_tunnel_private_keys + end + + def perform + validate_params + response + end + + def response + @response ||= client(use_json: false).post( + route, + payload + ) + end + + private + + def validate_params + raise ArgumentError, "source is required" if source.nil? + raise ArgumentError, "source does not exist" unless File.exist?(source) + raise ArgumentError, "source is not a zip file or directory" unless zip? || directory? + raise ArgumentError, "overwrite must be a boolean" unless [true, false].include?(overwrite) + end + + def payload + { + formData: Faraday::UploadIO.new(source_zip_file, "application/zip"), + overwrite: overwrite.to_s, + passwords: passwords.to_json, + ssh_tunnel_passwords: ssh_tunnel_passwords.to_json, + ssh_tunnel_private_key_passwords: ssh_tunnel_private_key_passwords.to_json, + ssh_tunnel_private_keys: ssh_tunnel_private_keys.to_json + } + end + + def route + "database/import/" + end + + def zip? + File.extname(source) == ".zip" + end + + def directory? + File.directory?(source) + end + + def source_zip_file + return source if zip? + + Zip::File.open(new_zip_file, Zip::File::CREATE) do |zipfile| + Dir[File.join(source, "**", "**")].each do |file| + zipfile.add(file.sub("#{source}/", "#{File.basename(source)}/"), file) if File.file?(file) + end + end + new_zip_file + end + + def new_zip_file + database_name = database_config[:databases].first[:content][:database_name] + File.join(source, "database_import.zip") + end + + def database_config + @database_config ||= zip? ? zip_database_config : directory_database_config + end + + def zip_database_config + Superset::Services::Loader.new(export_zip: source).perform + end + + def directory_database_config + Superset::Services::Loader::Config.new( + export_zip: "", tmp_uniq_path: source + ).config + end + end + end +end diff --git a/lib/superset/services/loader.rb b/lib/superset/services/loader.rb new file mode 100644 index 0000000..5d657f4 --- /dev/null +++ b/lib/superset/services/loader.rb @@ -0,0 +1,70 @@ +# Given a path, load all yaml files + +require "superset/file_utilities" +require "yaml" + +module Superset + module Services + class Loader + include FileUtilities + + TMP_PATH = "/tmp/superset_imports".freeze + + attr_reader :export_zip + + def initialize(export_zip:) + @export_zip = export_zip + end + + def perform + unzip_source_file + config + end + + def config + @config ||= Config.new( + export_zip: export_zip, + tmp_uniq_path: tmp_uniq_path + ).config + end + + private + + def unzip_source_file + @extracted_files = unzip_file(export_zip, tmp_uniq_path) + end + + def tmp_uniq_path + @tmp_uniq_path ||= File.join(TMP_PATH, uuid) + end + + def uuid + SecureRandom.uuid + end + + class Config < ::OpenStruct + def config + { + tmp_uniq_path: tmp_uniq_path, + dashboards: load_yamls_for("dashboards"), + databases: load_yamls_for("databases"), + datasets: load_yamls_for("datasets"), + charts: load_yamls_for("charts"), + metadata: load_yamls_for("metadata.yaml", pattern_sufix: nil) + } + end + + def load_yamls_for(object_path, pattern_sufix: "**/*.yaml") + pattern = File.join([tmp_uniq_path, "**", object_path, pattern_sufix].compact) + Dir.glob(pattern).map do |file| + { filename: file, content: load_yaml_and_symbolize_keys(file) } if File.file?(file) + end.compact + end + + def load_yaml_and_symbolize_keys(path) + YAML.load_file(path).deep_symbolize_keys + end + end + end + end +end diff --git a/spec/superset/dashboard/import_spec.rb b/spec/superset/dashboard/import_spec.rb index b4e1da8..67054e8 100644 --- a/spec/superset/dashboard/import_spec.rb +++ b/spec/superset/dashboard/import_spec.rb @@ -1,77 +1,285 @@ -require 'spec_helper' -require 'superset/dashboard/import' +# frozen_string_literal: true + +require "spec_helper" +require "superset/dashboard/import" +require "zip" RSpec.describe Superset::Dashboard::Import do - describe '#perform' do - context 'when the source zip file exists' do - let(:subject) { described_class.new(source_zip_file: source_zip_file, overwrite: overwrite) } - let(:source_zip_file) { 'spec/fixtures/dashboard_18_export_20240322.zip' } + describe "#perform" do + context "when the source is a directory" do + let(:subject) { described_class.new(source: source, overwrite: overwrite) } + let(:source) do + root_dir = File.expand_path("#{__dir__}/../../..") + tmp_dir = "#{root_dir}/tmp" + FileUtils.mkdir_p(tmp_dir) + + Zip::File.open("#{root_dir}/spec/fixtures/dashboard_18_export_20240322.zip") do |zip_file| + zip_file.each do |f| + fpath = File.join(tmp_dir, f.name) + FileUtils.mkdir_p(File.dirname(fpath)) + zip_file.extract(f, fpath) unless File.exist?(fpath) + end + end + "#{tmp_dir}/dashboard_export_20240321T214117" + end let(:overwrite) { true } let(:response) { { "result": "true" } } before { allow(subject).to receive(:response).and_return(response) } - describe '#response' do - context 'with valid parameters' do + describe "#response" do + context "with valid parameters" do before do - allow(Superset::Database::List).to receive(:new). - with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee"). - and_return(double(result: ['some data'])) + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: ["some data"])) end - specify 'returns response' do + specify "returns response" do expect(subject.perform).to eq(response) end end - context 'with invalid parameters' do + context "when source file does not exist" do + let(:source) { "./test" } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source does not exist") + end + end + + context "when database_config_not_found_in_superset is not present" do + before do + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: [])) + end + + specify "raises error" do + expect do + subject.perform + end.to raise_error(ArgumentError, + "target database does not exist: [{:uuid=>\"a2dc77af-e654-49bb-b321-40f6b559a1ee\", :name=>\"examples\"}]") + end + end + end + end + context "when the source zip file exists" do + let(:subject) { described_class.new(source: source, overwrite: overwrite) } + let(:source) { "spec/fixtures/dashboard_18_export_20240322.zip" } + let(:overwrite) { true } + + let(:response) { { "result": "true" } } + + before { allow(subject).to receive(:response).and_return(response) } + + describe "#response" do + context "with valid parameters" do + before do + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: ["some data"])) + end + + specify "returns response" do + expect(subject.perform).to eq(response) + end + end - context 'when source zip file is nil' do - let(:source_zip_file) { nil } + context "with invalid parameters" do + context "when source zip file is nil" do + let(:source) { nil } - specify 'raises error' do - expect { subject.perform }.to raise_error(ArgumentError, 'source_zip_file is required') + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source is required") end end - context 'when source file does not exist' do - let(:source_zip_file) { './test.zip' } + context "when source file does not exist" do + let(:source) { "./test.zip" } - specify 'raises error' do - expect { subject.perform }.to raise_error(ArgumentError, 'source_zip_file does not exist') + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source does not exist") end end - context 'when overwrite is not a boolean' do - let(:overwrite) { 'blah' } + context "when overwrite is not a boolean" do + let(:overwrite) { "blah" } - specify 'raises error' do - expect { subject.perform }.to raise_error(ArgumentError, 'overwrite must be a boolean') + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "overwrite must be a boolean") end end - context 'when source_zip_file is not a zip extension' do - let(:source_zip_file) { 'spec/fixtures/database-prod-examples.yaml' } + context "when source is not a zip extension" do + let(:source) { "spec/fixtures/database-prod-examples.yaml" } - specify 'raises error' do - expect { subject.perform }.to raise_error(ArgumentError, 'source_zip_file is not a zip file') + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source is not a zip file or directory") end end - context 'when zip_database_config_not_found_in_superset is not present' do + context "when database_config_not_found_in_superset is not present" do before do - allow(Superset::Database::List).to receive(:new). - with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee"). - and_return(double(result: [])) + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: [])) end - specify 'raises error' do - expect { subject.perform }.to raise_error(ArgumentError, "zip target database does not exist: [{:uuid=>\"a2dc77af-e654-49bb-b321-40f6b559a1ee\", :name=>\"examples\"}]") + specify "raises error" do + expect do + subject.perform + end.to raise_error(ArgumentError, + "target database does not exist: [{:uuid=>\"a2dc77af-e654-49bb-b321-40f6b559a1ee\", :name=>\"examples\"}]") end end end end end end + + describe "#source_zip_file" do + let(:subject) { described_class.new(source: source, overwrite: true) } + + context "when source is already a zip file" do + let(:source) { "spec/fixtures/dashboard_18_export_20240322.zip" } + + before do + allow(File).to receive(:extname).with(source).and_return(".zip") + end + + it "returns the source path unchanged" do + expect(subject.send(:source_zip_file)).to eq(source) + end + end + + context "when source is a directory" do + let(:source) { "spec/fixtures/dashboard_export_20240321T214117" } + let(:new_zip_file) { "#{source}/dashboard_import.zip" } + let(:dashboard_config) do + { + databases: [ + { + content: { + database_name: "examples" + } + } + ] + } + end + + before do + allow(File).to receive(:extname).with(source).and_return("") + allow(File).to receive(:directory?).with(source).and_return(true) + allow(subject).to receive(:dashboard_config).and_return(dashboard_config) + allow(subject).to receive(:new_zip_file).and_return(new_zip_file) + + # Mock Zip::File.open to prevent actual zip creation + zip_file_double = double("Zip::File") + allow(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE).and_yield(zip_file_double) + allow(zip_file_double).to receive(:add) + + # Mock Dir[] to return a list of files + allow(Dir).to receive(:[]).with(File.join(source, "**", "**")).and_return( + ["#{source}/file1.yaml", "#{source}/subdirectory/file2.yaml"] + ) + + # Mock File.file? to simulate real files + allow(File).to receive(:file?).and_return(true) + end + + it "creates a zip file and returns its path" do + expect(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE) + expect(subject.send(:source_zip_file)).to eq(new_zip_file) + end + + it "adds directory content to the zip file" do + zip_file_double = double("Zip::File") + expect(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE).and_yield(zip_file_double) + expect(zip_file_double).to receive(:add).with( + "dashboard_export_20240321T214117/file1.yaml", "#{source}/file1.yaml" + ) + expect(zip_file_double).to receive(:add).with( + "dashboard_export_20240321T214117/subdirectory/file2.yaml", "#{source}/subdirectory/file2.yaml" + ) + + subject.send(:source_zip_file) + end + end + + context "integration between source_zip_file and new_zip_file methods" do + let(:source) { "spec/fixtures/dashboard_export_20240321T214117" } + let(:dashboard_config) do + { + databases: [ + { + content: { + database_name: "custom_database" + } + } + ] + } + end + + before do + allow(File).to receive(:extname).with(source).and_return("") + allow(File).to receive(:directory?).with(source).and_return(true) + allow(subject).to receive(:dashboard_config).and_return(dashboard_config) + + # Skip actual zip creation + zip_file_double = double("Zip::File") + allow(Zip::File).to receive(:open).and_yield(zip_file_double) + allow(zip_file_double).to receive(:add) + allow(Dir).to receive(:[]).and_return([]) + end + + it "creates a zip file in the source directory" do + expected_path = "#{source}/dashboard_import.zip" + expect(subject.send(:source_zip_file)).to eq(expected_path) + end + end + + context "when source is neither a zip file nor a directory" do + let(:source) { "spec/fixtures/some_invalid_file.txt" } + + before do + allow(File).to receive(:extname).with(source).and_return(".txt") + allow(File).to receive(:directory?).with(source).and_return(false) + allow(File).to receive(:exist?).with(source).and_return(true) + end + + it "raises an error during validation" do + expect { subject.perform }.to raise_error(ArgumentError, "source is not a zip file or directory") + end + end + end + + describe "#new_zip_file" do + let(:subject) { described_class.new(source: source, overwrite: true) } + let(:source) { "spec/fixtures/dashboard_export_20240321T214117" } + + context "when dashboard config has databases" do + let(:dashboard_config) do + { + databases: [ + { + content: { + database_name: "test_database" + } + } + ] + } + end + + before do + allow(subject).to receive(:dashboard_config).and_return(dashboard_config) + end + + it "creates a zip file path using the source directory" do + expected_path = "#{source}/dashboard_import.zip" + expect(subject.send(:new_zip_file)).to eq(expected_path) + end + end + end end diff --git a/spec/superset/database/import_spec.rb b/spec/superset/database/import_spec.rb new file mode 100644 index 0000000..a9e1f31 --- /dev/null +++ b/spec/superset/database/import_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require "spec_helper" +require "superset/database/import" +require "zip" + +RSpec.describe Superset::Database::Import do + describe "#perform" do + context "when the source is a directory" do + let(:subject) { described_class.new(source: source, overwrite: overwrite) } + let(:source) do + root_dir = File.expand_path("#{__dir__}/../../..") + tmp_dir = "#{root_dir}/tmp" + FileUtils.mkdir_p(tmp_dir) + + # Create a directory structure from the test zip file + Zip::File.open("#{root_dir}/spec/fixtures/database_1_export_20240903.zip") do |zip_file| + zip_file.each do |f| + fpath = File.join(tmp_dir, f.name) + FileUtils.mkdir_p(File.dirname(fpath)) + zip_file.extract(f, fpath) unless File.exist?(fpath) + end + end + "#{tmp_dir}/database_export_20240903T014207" + end + let(:overwrite) { true } + let(:response) { { "result": "true" } } + + before do + allow(subject).to receive(:response).and_return(response) + # Mock the database configuration with a valid database UUID + database_config = { + databases: [ + { + content: { + uuid: "a2dc77af-e654-49bb-b321-40f6b559a1ee", + database_name: "examples" + } + } + ] + } + allow(subject).to receive(:database_config).and_return(database_config) + end + + describe "#response" do + context "with valid parameters" do + before do + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: ["some data"])) + end + + specify "returns response" do + expect(subject.perform).to eq(response) + end + end + + context "when source file does not exist" do + let(:source) { "./nonexistent_dir" } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source does not exist") + end + end + end + end + + context "when the source zip file exists" do + let(:subject) { described_class.new(source: source, overwrite: overwrite) } + let(:source) { "spec/fixtures/database_1_export_20240903.zip" } + let(:overwrite) { true } + let(:response) { { "result": "true" } } + + before do + allow(subject).to receive(:response).and_return(response) + # Mock the database configuration with a valid database UUID + database_config = { + databases: [ + { + content: { + uuid: "a2dc77af-e654-49bb-b321-40f6b559a1ee", + database_name: "examples" + } + } + ] + } + allow(subject).to receive(:database_config).and_return(database_config) + end + + describe "#response" do + context "with valid parameters" do + before do + allow(Superset::Database::List).to receive(:new) + .with(uuid_equals: "a2dc77af-e654-49bb-b321-40f6b559a1ee") + .and_return(double(result: ["some data"])) + end + + specify "returns response" do + expect(subject.perform).to eq(response) + end + end + + context "with invalid parameters" do + context "when source zip file is nil" do + let(:source) { nil } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source is required") + end + end + + context "when source file does not exist" do + let(:source) { "./nonexistent.zip" } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source does not exist") + end + end + + context "when overwrite is not a boolean" do + let(:overwrite) { "not_a_boolean" } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "overwrite must be a boolean") + end + end + + context "when source is not a zip extension or directory" do + let(:source) { "spec/fixtures/database-prod-examples.yaml" } + + specify "raises error" do + expect { subject.perform }.to raise_error(ArgumentError, "source is not a zip file or directory") + end + end + end + end + end + end + + describe "#source_zip_file" do + let(:subject) { described_class.new(source: source, overwrite: true) } + + context "when source is already a zip file" do + let(:source) { "spec/fixtures/database_1_export_20240903.zip" } + + before do + allow(File).to receive(:extname).with(source).and_return(".zip") + end + + it "returns the source path unchanged" do + expect(subject.send(:source_zip_file)).to eq(source) + end + end + + context "when source is a directory" do + let(:source) { "spec/fixtures/database_export_20240903" } + let(:new_zip_file) { "#{source}/database_import.zip" } + let(:database_config) do + { + databases: [ + { + content: { + database_name: "examples" + } + } + ] + } + end + + before do + allow(File).to receive(:extname).with(source).and_return("") + allow(File).to receive(:directory?).with(source).and_return(true) + allow(subject).to receive(:database_config).and_return(database_config) + allow(subject).to receive(:new_zip_file).and_return(new_zip_file) + + # Mock Zip::File.open to prevent actual zip creation + zip_file_double = double("Zip::File") + allow(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE).and_yield(zip_file_double) + allow(zip_file_double).to receive(:add) + + # Mock Dir[] to return a list of files + allow(Dir).to receive(:[]).with(File.join(source, "**", "**")).and_return( + ["#{source}/file1.yaml", "#{source}/subdirectory/file2.yaml"] + ) + + # Mock File.file? to simulate real files + allow(File).to receive(:file?).and_return(true) + end + + it "creates a zip file and returns its path" do + expect(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE) + expect(subject.send(:source_zip_file)).to eq(new_zip_file) + end + + it "adds directory content to the zip file" do + zip_file_double = double("Zip::File") + expect(Zip::File).to receive(:open).with(new_zip_file, Zip::File::CREATE).and_yield(zip_file_double) + expect(zip_file_double).to receive(:add).with( + "database_export_20240903/file1.yaml", "#{source}/file1.yaml" + ) + expect(zip_file_double).to receive(:add).with( + "database_export_20240903/subdirectory/file2.yaml", "#{source}/subdirectory/file2.yaml" + ) + + subject.send(:source_zip_file) + end + end + end + + describe "#new_zip_file" do + let(:subject) { described_class.new(source: source, overwrite: true) } + let(:source) { "spec/fixtures/database_export_20240903" } + + context "when database config has databases" do + let(:database_config) do + { + databases: [ + { + content: { + database_name: "test_database" + } + } + ] + } + end + + before do + allow(subject).to receive(:database_config).and_return(database_config) + end + + it "creates a zip file path using the source directory" do + expected_path = "#{source}/database_import.zip" + expect(subject.send(:new_zip_file)).to eq(expected_path) + end + end + end + + describe "#payload" do + let(:subject) { + described_class.new( + source: source, + overwrite: overwrite, + passwords: passwords, + ssh_tunnel_passwords: ssh_tunnel_passwords, + ssh_tunnel_private_key_passwords: ssh_tunnel_private_key_passwords, + ssh_tunnel_private_keys: ssh_tunnel_private_keys + ) + } + let(:source) { "spec/fixtures/database_1_export_20240903.zip" } + let(:overwrite) { true } + let(:passwords) { {"databases/MyDatabase.yaml": "db_password"} } + let(:ssh_tunnel_passwords) { {"databases/MyDatabase.yaml": "ssh_password"} } + let(:ssh_tunnel_private_key_passwords) { {"databases/MyDatabase.yaml": "key_password"} } + let(:ssh_tunnel_private_keys) { {"databases/MyDatabase.yaml": "private_key_content"} } + + before do + allow(subject).to receive(:source_zip_file).and_return(source) + allow(Faraday::UploadIO).to receive(:new).with(source, "application/zip").and_return("mock_upload_io") + end + + it "includes all required parameters in the payload" do + payload = subject.send(:payload) + expect(payload).to include( + formData: "mock_upload_io", + overwrite: "true", + passwords: passwords.to_json, + ssh_tunnel_passwords: ssh_tunnel_passwords.to_json, + ssh_tunnel_private_key_passwords: ssh_tunnel_private_key_passwords.to_json, + ssh_tunnel_private_keys: ssh_tunnel_private_keys.to_json + ) + end + + context "with default SSH parameters" do + let(:subject) { described_class.new(source: source, overwrite: overwrite) } + + it "sends empty JSON objects for SSH-related parameters" do + payload = subject.send(:payload) + expect(payload).to include( + passwords: "{}", + ssh_tunnel_passwords: "{}", + ssh_tunnel_private_key_passwords: "{}", + ssh_tunnel_private_keys: "{}" + ) + end + end + end + + describe "#route" do + let(:subject) { described_class.new(source: "spec/fixtures/database_1_export_20240903.zip", overwrite: true) } + + it "returns the correct API endpoint" do + expect(subject.send(:route)).to eq("database/import/") + end + end +end