From baf44c85deb30e6ab5ea5fa5ebc5adeef6f60ef6 Mon Sep 17 00:00:00 2001 From: nfstern02 <72567812+nfstern02@users.noreply.github.com> Date: Wed, 29 May 2024 13:22:42 -0500 Subject: [PATCH] Vfep 1478 - Adding/updating tests to increase coverage on GIBCT (#1134) * add tests * refactoring and more tests * fix download_csv to not call externally and stub response correctly * temporary test to check why spec is failing * temporary test to check why spec is failing * temporary test to check why spec is failing * remove puts, add expect for hcm * adjust specs to try to fix sprockets issue. change dashboards controller spec * fix failing test for dashboards controller spec * fix failing test for dashboards controller spec * fix failing test for dashboards controller spec * fix failing test for dashboards controller spec * fix failing test for dashboards controller spec - nocov * fix unzipper spec * fix unzipper spec * fix unzipper spec * fix unzipper spec * fix unzipper spec * fix permissions on tmp * fix permissions on tmp * fix permissions on tmp * fix permissions on tmp * revert Dockerfile * Dockerfile see what filesystem looks lik * Dockerfile see what filesystem looks like * Dockerfile see what filesystem looks like * add comment to Dockerfile fix, add back in tests that were failing due to tmp permissions issue * remove comment from Dockerfile * ls -l in Dockerfile * remove fix for sprockets, revert Dockerfile to original --- app/controllers/dashboards_controller.rb | 115 +++--------------- .../file_type_converters/xls_to_csv.rb | 35 ++++++ .../no_key_apis/no_key_api_downloader.rb | 65 ++++++++++ app/utilities/zip_file_utils/unzipper.rb | 26 ++++ config/initializers/csv_types.rb | 10 +- config/initializers/group_types.rb | 4 +- config/initializers/upload_types.rb | 8 +- .../controllers/dashboards_controller_spec.rb | 25 ++-- .../v0/institutions_controller_spec.rb | 14 --- spec/fixtures/download_8_keys_sites.xls | Bin 0 -> 6656 bytes spec/fixtures/download_hcm.zip | Bin 0 -> 452 bytes spec/fixtures/download_hcm_corrupt.zip | Bin 0 -> 300 bytes spec/models/accreditation_action_spec.rb | 30 +++++ .../accreditation_institute_campus_spec.rb | 5 + spec/models/accreditation_record_spec.rb | 25 ++++ spec/models/calculator_constant_spec.rb | 4 + spec/models/outcome_spec.rb | 48 ++++++++ spec/models/post911_stat_spec.rb | 4 + .../file_type_converters/xls_to_csv_spec.rb | 21 ++++ .../no_key_apis/no_key_api_downloader_spec.rb | 103 ++++++++++++++++ .../utilities/zip_file_utils/unzipper_spec.rb | 34 ++++++ 21 files changed, 441 insertions(+), 135 deletions(-) create mode 100644 app/utilities/file_type_converters/xls_to_csv.rb create mode 100644 app/utilities/no_key_apis/no_key_api_downloader.rb create mode 100644 app/utilities/zip_file_utils/unzipper.rb create mode 100644 spec/fixtures/download_8_keys_sites.xls create mode 100644 spec/fixtures/download_hcm.zip create mode 100644 spec/fixtures/download_hcm_corrupt.zip create mode 100644 spec/utilities/file_type_converters/xls_to_csv_spec.rb create mode 100644 spec/utilities/no_key_apis/no_key_api_downloader_spec.rb create mode 100644 spec/utilities/zip_file_utils/unzipper_spec.rb diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 7a2980e17..2494945f2 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -165,31 +165,25 @@ def flash_progress_if_needed def upload_file(class_nm, csv) if CSV_TYPES_NO_API_KEY_TABLE_NAMES.include?(class_nm) klass = Object.const_get(class_nm) - # added klass to unzip_csv as sometimes the download does not need unzipped - if download_csv(klass) && unzip_csv(klass) + + if download_csv(class_nm) && unzip_csv(class_nm) upload = Upload.from_csv_type(params[:csv_type]) upload.user = current_user - file = "tmp/#{params[:csv_type]}s.csv" - file = 'tmp/InstitutionCampus.csv' if class_nm.eql?('AccreditationInstituteCampus') - file = 'tmp/hd2022.csv' if class_nm.eql?('IpedsHd') - file = 'tmp/ic2022_ay.csv' if class_nm.eql?('IpedsIcAy') - file = 'tmp/ic2022_py.csv' if class_nm.eql?('IpedsIcPy') - file = 'tmp/ic2022.csv' if class_nm.eql?('IpedsIc') - file = 'tmp/hcm.xlsx' if klass.name.eql?('Hcm') - if klass.name.eql?('EightKey') - file = 'tmp/eight_key.xls' - convert_xls_to_csv(file, 'tmp/eight_key.csv') - file = 'tmp/eight_key.csv' - end - skipline = 0 - skipline = 2 if klass.name.eql?('Hcm') - - upload.csv = file + upload.csv = + if class_nm.eql?('EightKey') # This guy doesn't load properly with roo as is. + FileTypeConverters::XlsToCsv.new('tmp/eight_key.xls', 'tmp/eight_key.csv').convert_xls_to_csv + else + NoKeyApis::NoKeyApiDownloader::API_DOWNLOAD_CONVERSION_NAMES[class_nm] || "tmp/#{params[:csv_type]}s.csv" + end + + skipline = class_nm.eql?('Hcm') ? 2 : 0 + file_options = { liberal_parsing: upload.liberal_parsing, sheets: [{ klass: klass, skip_lines: skipline, clean_rows: upload.clean_rows }] } - klass.load_with_roo(file, file_options).first + + klass.load_with_roo(upload.csv, file_options).first upload.update(ok: true, completed_at: Time.now.utc.to_fs(:db)) flash.notice = 'Successfully fetched & uploaded file' if upload.save! end @@ -205,85 +199,14 @@ def upload_file(class_nm, csv) flash.alert = message end - # :nocov: - def download_csv(klass) - # rubocop:disable Style/EmptyCaseCondition - # the most recent IPED data files are from 2022. This should be checked periodically. - # the most recent Hcm data files are from 2020. This should be checked periodically. - case - when klass.name.start_with?('Accreditation') - _stdout, _stderr, status = Open3.capture3("curl -X POST \ - https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles \ - -H 'Content-Type: application/json' -d '{\"CSVChecked\":true,\"ExcelChecked\":false}' -o tmp/download.zip") - when klass.name.eql?('IpedsHd') - _stdout, _stderr, status = Open3.capture3("curl -X GET \ - https://nces.ed.gov/ipeds/datacenter/data/HD2022.zip \ - -H 'Content-Type: application/json' -o tmp/download.zip") - when klass.name.eql?('IpedsIcAy') - _stdout, _stderr, status = Open3.capture3("curl -X GET \ - https://nces.ed.gov/ipeds/datacenter/data/IC2022_AY.zip \ - -H 'Content-Type: application/json' -o tmp/download.zip") - when klass.name.eql?('IpedsIcPy') - _stdout, _stderr, status = Open3.capture3("curl -X GET \ - https://nces.ed.gov/ipeds/datacenter/data/IC2022_PY.zip \ - -H 'Content-Type: application/json' -o tmp/download.zip") - when klass.name.eql?('IpedsIc') - _stdout, _stderr, status = Open3.capture3("curl -X GET \ - https://nces.ed.gov/ipeds/datacenter/data/IC2022.zip \ - -H 'Content-Type: application/json' -o tmp/download.zip") - when klass.name.eql?('Hcm') # without User-Agent server blocks download - _stdout, _stderr, status = Open3.capture3('curl -o tmp/hcm.xlsx \ - -H "User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0" \ - https://studentaid.gov/sites/default/files/Schools-on-HCM-December2023.xlsx') - when klass.name.eql?('EightKey') - _stdout, _stderr, status = Open3.capture3("curl -X GET \ - https://www2.ed.gov/documents/military/8-keys-sites.xls \ - -H 'Content-Type: application/json' -o tmp/eight_key.xls") - - end - # rubocop:enable Style/EmptyCaseCondition - status.success? + def download_csv(class_nm) + NoKeyApis::NoKeyApiDownloader.new(class_nm).download_csv end - # :nocov: - # This is a candidate for a utility class. Right now, this is the only place we needed it. - # If some other process needs it, it should probably be refactored to a utility class. - def unzip_csv(klass) - # Some downloads do are not a zip file, so return true - return true if klass.name.eql?('Hcm') || klass.name.eql?('EightKey') - - Zip::File.open('tmp/download.zip') do |zip_file| - zip_file.each do |f| - f_path = File.join('tmp', f.name) - FileUtils.mkdir_p(File.dirname(f_path)) unless File.exist?(File.dirname(f_path)) - File.delete(f_path) if File.exist?(f_path) - zip_file.extract(f, f_path) - end - end - true - rescue StandardError => _e - false - end + def unzip_csv(class_nm) + # Some downloads do are not a zip file, so skip and return true + return true if class_nm.eql?('Hcm') || class_nm.eql?('EightKey') - def convert_xls_to_csv(xls_path, csv_path) - book = Spreadsheet.open(xls_path) - sheet = book.worksheet(0) # Assuming the 'opeid' is in the first sheet - - CSV.open(csv_path, 'wb') do |csv| - sheet.each do |row| - formatted_row = row.to_a.map do |cell| - cell_value = cell.is_a?(Float) ? format('%.0f', cell) : cell.to_s.strip - # Apply zero-padding for 'opeid' if necessary - if cell_value =~ /^\d+$/ && cell_value.length <= 8 - # Format the number to be exactly eight digits - formatted_number = cell_value.rjust(8, '0') - formatted_number - else - cell_value - end - end - csv << formatted_row - end - end + ZipFileUtils::Unzipper.new.unzip_the_file end end diff --git a/app/utilities/file_type_converters/xls_to_csv.rb b/app/utilities/file_type_converters/xls_to_csv.rb new file mode 100644 index 000000000..a6d1335cd --- /dev/null +++ b/app/utilities/file_type_converters/xls_to_csv.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# some older versions of Excel spreadsheets don't work well with Roo. +module FileTypeConverters + class XlsToCsv + attr_accessor :xls_file_name, :csv_file_name + + def initialize(xls_file_name, csv_file_name) + @xls_file_name = xls_file_name + @csv_file_name = csv_file_name + end + + def convert_xls_to_csv + book = Spreadsheet.open(@xls_file_name) + sheet = book.worksheet(0) + + CSV.open(@csv_file_name, 'wb') do |csv| + sheet.each do |row| + formatted_row = row.to_a.map do |cell| + cell_value = cell.is_a?(Float) ? format('%.0f', cell) : cell.to_s.strip + if cell_value =~ /^\d+$/ && cell_value.length <= 8 + # Format the number to be exactly eight digits + formatted_number = cell_value.rjust(8, '0') + formatted_number + else + cell_value + end + end + csv << formatted_row + end + end + @csv_file_name + end + end +end diff --git a/app/utilities/no_key_apis/no_key_api_downloader.rb b/app/utilities/no_key_apis/no_key_api_downloader.rb new file mode 100644 index 000000000..3447895e5 --- /dev/null +++ b/app/utilities/no_key_apis/no_key_api_downloader.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module NoKeyApis + class NoKeyApiDownloader + # the most recent IPED data files are from 2022. This should be checked periodically. + # the most recent Hcm data files are from 2020. This should be checked periodically. + # changes will need to be made to both hashes when these change + API_DOWNLOAD_CONVERSION_NAMES = { + 'AccreditationInstituteCampus' => 'tmp/InstitutionCampus.csv', + 'Hcm' => 'tmp/hcm.xlsx', + 'IpedsHd' => 'tmp/hd2022.csv', + 'IpedsIcAy' => 'tmp/ic2022_ay.csv', + 'IpedsIcPy' => 'tmp/ic2022_py.csv', + 'IpedsIc' => 'tmp/ic2022.csv' + }.freeze + + API_NO_KEY_DOWNLOAD_SOURCES = { + 'Accreditation' => [' -X POST', 'https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles'], + 'AccreditationAction' => [' -X POST', 'https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles'], + 'AccreditationInstituteCampus' => [' -X POST', 'https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles'], + 'AccreditationRecord' => [' -X POST', 'https://ope.ed.gov/dapip/api/downloadFiles/accreditationDataFiles'], + 'EightKey' => [' -X GET', 'https://www2.ed.gov/documents/military/8-keys-sites.xls'], + 'Hcm' => ['', 'https://studentaid.gov/sites/default/files/Schools-on-HCM-December2023.xlsx'], + 'IpedsHd' => [' -X GET', 'https://nces.ed.gov/ipeds/datacenter/data/HD2022.zip'], + 'IpedsIc' => [' -X GET', 'https://nces.ed.gov/ipeds/datacenter/data/IC2022.zip'], + 'IpedsIcAy' => [' -X GET', 'https://nces.ed.gov/ipeds/datacenter/data/IC2022_AY.zip'], + 'IpedsIcPy' => [' -X GET', 'https://nces.ed.gov/ipeds/datacenter/data/IC2022_PY.zip'] + }.freeze + + attr_accessor :class_nm, :curl_command + + def initialize(class_nm) + @class_nm = class_nm + rest_command, url = API_NO_KEY_DOWNLOAD_SOURCES[@class_nm] + @curl_command = "curl#{rest_command} #{url} #{h_parm} #{o_parm}#{d_parm}" + end + + def download_csv + _stdout, _stderr, status = Open3.capture3(@curl_command) + + status.success? + end + + private + + def h_parm + return '-H "User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"' if @class_nm.eql?('Hcm') + + '-H \'Content-Type: application/json\'' + end + + def o_parm + return '-o tmp/hcm.xlsx' if @class_nm.eql?('Hcm') + return '-o tmp/eight_key.xls' if @class_nm.eql?('EightKey') + + '-o tmp/download.zip' + end + + def d_parm + return '' unless @class_nm.start_with?('Accreditation') + + " -d '{\"CSVChecked\":true,\"ExcelChecked\":false}'" + end + end +end diff --git a/app/utilities/zip_file_utils/unzipper.rb b/app/utilities/zip_file_utils/unzipper.rb new file mode 100644 index 000000000..d6243c10c --- /dev/null +++ b/app/utilities/zip_file_utils/unzipper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ZipFileUtils + class Unzipper + attr_accessor :zip_file_name + + # default to tmp/download.zip if nothing's passed in + def initialize(zip_file_name = 'tmp/download.zip') + @zip_file_name = zip_file_name + end + + def unzip_the_file + Zip::File.open(@zip_file_name) do |zip_file| + zip_file.each do |f| + f_path = File.join('tmp', f.name) + FileUtils.mkdir_p(File.dirname(f_path)) unless File.exist?(File.dirname(f_path)) + File.delete(f_path) if File.exist?(f_path) + zip_file.extract(f, f_path) + end + end + true + rescue StandardError => _e + false + end + end +end diff --git a/config/initializers/csv_types.rb b/config/initializers/csv_types.rb index f5706ad3a..5cf8bdf4c 100644 --- a/config/initializers/csv_types.rb +++ b/config/initializers/csv_types.rb @@ -1,5 +1,5 @@ Rails.application.config.to_prepare do - CSV_TYPES_TABLES = [ + CSV_TYPES_TABLES ||= [ { klass: AccreditationAction, required?: true, has_api?: true, no_api_key?: true }, { klass: AccreditationInstituteCampus, required?: true, has_api?: true, no_api_key?: true }, { klass: AccreditationRecord, required?: true, has_api?: true, no_api_key?: true }, @@ -38,8 +38,8 @@ { klass: Section1015, required?: false } ].freeze - CSV_TYPES_HAS_API_TABLE_NAMES = CSV_TYPES_TABLES.select { |table| table[:has_api?] }.map { |table| table[:klass].name }.freeze - CSV_TYPES_NO_API_KEY_TABLE_NAMES = CSV_TYPES_TABLES.select { |table| table[:no_api_key?] }.map { |table| table[:klass].name }.freeze - CSV_TYPES_NO_UPLOAD_TABLE_NAMES = CSV_TYPES_TABLES.select { |table| table[:no_upload?] }.map { |table| table[:klass].name }.freeze - CSV_TYPES_ALL_TABLES_CLASSES = CSV_TYPES_TABLES.map { |table| table[:klass] }.freeze + CSV_TYPES_HAS_API_TABLE_NAMES ||= CSV_TYPES_TABLES.select { |table| table[:has_api?] }.map { |table| table[:klass].name }.freeze + CSV_TYPES_NO_API_KEY_TABLE_NAMES ||= CSV_TYPES_TABLES.select { |table| table[:no_api_key?] }.map { |table| table[:klass].name }.freeze + CSV_TYPES_NO_UPLOAD_TABLE_NAMES ||= CSV_TYPES_TABLES.select { |table| table[:no_upload?] }.map { |table| table[:klass].name }.freeze + CSV_TYPES_ALL_TABLES_CLASSES ||= CSV_TYPES_TABLES.map { |table| table[:klass] }.freeze end diff --git a/config/initializers/group_types.rb b/config/initializers/group_types.rb index 464830b58..38773be50 100644 --- a/config/initializers/group_types.rb +++ b/config/initializers/group_types.rb @@ -1,5 +1,5 @@ Rails.application.config.to_prepare do - GROUP_FILE_TYPES = [ + GROUP_FILE_TYPES ||= [ { klass: 'Accreditation', required?: true, @@ -11,5 +11,5 @@ }, ].freeze - GROUP_FILE_TYPES_NAMES = GROUP_FILE_TYPES.map { |g| g[:klass] }.freeze + GROUP_FILE_TYPES_NAMES ||= GROUP_FILE_TYPES.map { |g| g[:klass] }.freeze end diff --git a/config/initializers/upload_types.rb b/config/initializers/upload_types.rb index 5cafad536..02a0fd4db 100644 --- a/config/initializers/upload_types.rb +++ b/config/initializers/upload_types.rb @@ -1,10 +1,10 @@ Rails.application.config.to_prepare do - UPLOAD_TYPES = [ + UPLOAD_TYPES ||= [ *GROUP_FILE_TYPES, *CSV_TYPES_TABLES, ].freeze - UPLOAD_TYPES_ALL_NAMES = UPLOAD_TYPES.map do |upload| + UPLOAD_TYPES_ALL_NAMES ||= UPLOAD_TYPES.map do |upload| klass = upload[:klass] if klass.is_a? String klass @@ -13,7 +13,7 @@ end end.freeze - UPLOAD_TYPES_REQUIRED_NAMES = UPLOAD_TYPES.select { |upload| upload[:required?] }.map do |upload| + UPLOAD_TYPES_REQUIRED_NAMES ||= UPLOAD_TYPES.select { |upload| upload[:required?] }.map do |upload| klass = upload[:klass] if klass.is_a? String klass @@ -22,7 +22,7 @@ end end.freeze - UPLOAD_TYPES_NO_PROD_NAMES = UPLOAD_TYPES.select { |upload| upload[:not_prod_ready?] }.map do |upload| + UPLOAD_TYPES_NO_PROD_NAMES ||= UPLOAD_TYPES.select { |upload| upload[:not_prod_ready?] }.map do |upload| klass = upload[:klass] if klass.is_a? String klass diff --git a/spec/controllers/dashboards_controller_spec.rb b/spec/controllers/dashboards_controller_spec.rb index 1e455f9c9..69428168b 100644 --- a/spec/controllers/dashboards_controller_spec.rb +++ b/spec/controllers/dashboards_controller_spec.rb @@ -178,33 +178,30 @@ def load_table(klass) expect(flash.alert).to include(message) end - context 'when fetching Accreditation files which do not require an api key' do + context 'when fetching files which do not require an api key' do before do - system('cp spec/fixtures/Accreditation/download.zip tmp') + system('cp spec/fixtures/Accreditation/download.zip tmp/download.zip') + system('cp spec/fixtures/download_8_keys_sites.xls tmp/eight_key.xls') end + # rubocop:disable RSpec/AnyInstance it 'downloads a zip file from the edu website' do - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(described_class).to receive(:download_csv).and_return(true) - # rubocop:enable RSpec/AnyInstance - - CSV_TYPES_NO_API_KEY_TABLE_NAMES.each do |klass_nm| - get(:api_fetch, params: { csv_type: klass_nm }) - expect(File.exist?('tmp/download.zip')).to be true - end + allow_any_instance_of(NoKeyApis::NoKeyApiDownloader).to receive(:download_csv).and_return(true) + + get(:api_fetch, params: { csv_type: 'EightKey' }) + expect(Upload.last.ok).to be true end it 'extracts the content of the zip file' do - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(described_class).to receive(:download_csv).and_return(true) - # rubocop:enable RSpec/AnyInstance + allow_any_instance_of(NoKeyApis::NoKeyApiDownloader).to receive(:download_csv).and_return(true) - get(:api_fetch, params: { csv_type: CSV_TYPES_NO_API_KEY_TABLE_NAMES.first }) + get(:api_fetch, params: { csv_type: 'AccreditationAction' }) expect(File.exist?('tmp/AccreditationActions.csv')).to be true expect(File.exist?('tmp/AccreditationRecords.csv')).to be true expect(File.exist?('tmp/InstitutionCampus.csv')).to be true end + # rubocop:enable RSpec/AnyInstance end end diff --git a/spec/controllers/v0/institutions_controller_spec.rb b/spec/controllers/v0/institutions_controller_spec.rb index 70f03c06d..7f3cb13fb 100644 --- a/spec/controllers/v0/institutions_controller_spec.rb +++ b/spec/controllers/v0/institutions_controller_spec.rb @@ -8,16 +8,6 @@ def expect_response_match_schema(schema) expect(response).to match_response_schema(schema) end - def expect_meta_body_eq_preview(body, version_preview_number) - expect(body['meta']['version']['number'].to_i).to eq(version_preview_number) - end - - def create_extension_institutions(trait) - create(:institution, trait, campus_type: 'E') - create(:institution, trait, campus_type: 'Y') - create(:institution, trait) - end - context 'with version determination' do it 'uses a production version as a default' do create(:version, :production, :with_institution_that_contains_harv) @@ -25,10 +15,6 @@ def create_extension_institutions(trait) expect_response_match_schema('institutions') end - def preview_body(body) - JSON.parse(body) - end - it 'accepts invalid version parameter and returns production data' do create(:version, :production, :with_institution_that_contains_harv) get(:index, params: { version: 'invalid_data' }) diff --git a/spec/fixtures/download_8_keys_sites.xls b/spec/fixtures/download_8_keys_sites.xls new file mode 100644 index 0000000000000000000000000000000000000000..01d097b9ac966fc4a25086078573fec86146a45c GIT binary patch literal 6656 zcmeHLU2IfE6h3$VdRw5|@+ZIbQh~O#RL}<@itW-?X{6A!R1-rY+wE;x+3u3vTVsts zi;D395)BW&kQfXvjftQpXtYLn<3Xc>#6Wx~J{XNWECyppfc5+4?shMmUD*X=0_n8p z&YUx6&Y3xL=A4=R{=3?#BOfohD0$^kam$T#rT83l4ehEdUL)c{o3tD0bUI5$bEe!y z7Py|7EBDZYP4oh|=M?}QPzhk)tW13uEp2A1C0L+OV)(PfkS>X$j>|Z9y7aOT9^+I~ z%1CFtD24x4JTv-p7PytI*zdGI<2U2D8&K^0Gv@RB&j!o^R0I3~qyVV}%mvH?%m*w0 zECk#KSOi!MSOQoISO!=Qr~}*&FaRq6^?(NeD*>wjs{x#2W2R=i8SNIpT0m=tzAjVe zRzK4!*8-I~cG2gRFXw#2kgsC@MR>$<&ne{>zks$=_R=>xf{~E%f0fN@4nd8+c@j># z2`xRXMkCZDM>7ZhbZ{P1V+>+(+K z5L9R#5|oI9We{;NEbHO9r@@haO>v;*)dH%ATzA%M>#qD4-F31E^U`IKCIIBmuFf-C zjxtX_X-3U>Ffygi-%_VA#9xd~sfGCOUHVbj)Zp?mC2Qwq z+YYIGdQ%a4OA-2-BJ|3FbT2$X&4*VuRqXlr3eXR^aF&yjW?hqXtGYJnQ}x`WN7eI^ z6{?<}^rm;IGqWGj{}Wyg5ebHZR`3974S%OJ9U!vfpeBFFv&;*rJ~abhit(R;gI+@q zbi%_jE5*F=H9TCGeH!kW`bnHy(c7i&GBXP@jgE|?Ba`TVBiUS&^UA}SeULr6qX{c) zjalJX)QAlmTf!r|&A8DuG!_by#&{LB#xdi5D`=UhxA%5+cjCBo_jYymcXxuaEoPYs zSgd29uMfR_=nQNPY!37Ukii0x;9zhxXarXG7z5Gp3uZh4dAJ&Hi^WHS5v(b&6${L= zDJ>30$_wydHLNQ<@)J*PZt)QD#FeO>Wp zGx-~e_NMx-#`s}Xer;=Tv>T`8{?^vyXdjV!%w`CB=qD~{Ytd$%*oFV8wrs7*0D5$J zMJ-!oqRL(>agJ(wjQ0#t{Vs4$)aGzE?ks10IoyMrXn$1>_aT=}tjOWmUG1!HbA7h1ZDKBuAmGTjtSzoKR5?xfM5b*#rp4pARBW7 zqjVed0Ha(R^8%wp8}k9N1d*7-<~_%y0qrdd$3>2)sH$s z^XAcZg2p^eWd!;=$I>>%=;)p)_lO1l%in*L|K!D>LS|xAZ0CVQc?Q2e#bpm(T>Kqs zk4-G&%S=Uo<@wLX(o%*WV4y(Jqyu_K@xtAXIXrI@%lJ}1*F)Vbr3`HVQ+I|@gWiak zr3@>`!<=kuw!a0L@`wyzyy3Cn&Lm@4|0rI*{OzwBzXA@cJt4)yD>w#9@zR*l&$~#e y>t6%jWtr&al}=~~sX3VH7s&GFv*9+@{~+{R#g|lePr3GY{{B|BvH}C{ME^h0oMG_* literal 0 HcmV?d00001 diff --git a/spec/fixtures/download_hcm.zip b/spec/fixtures/download_hcm.zip new file mode 100644 index 0000000000000000000000000000000000000000..e8d70272af957479c6635f5d90541904a23727e9 GIT binary patch literal 452 zcmWIWW@Zs#-~d9dh}dujC}?10U|?rZV8}?$)k`ie3k~6AV4u=xmukfvm4BUNRw^s1>PFhoPty}gYMV8n_>30 zL4Vu7i2u*+rKOdQ+ilv+IH&dbrc(!hZam-UV&!D#|G8w|Q|>+2RBrCRXa0-vO4Qr> zqv2cI&T^U8w{2Lw`Pih#4Zl4C-LBt$(Oz%;-g)`co23gfW`v$A5~*4G_@wNITMvAL zCtRJ%cUC1*@BRBtJlxI?CqI~&y?9~Cu@_;Bm5Q&)b&DTa`Kd;7;fCUEpWmG*?3%eQu7zH`;D&SYaR)>{9y|4X6F$0byZ*i1|2B)0=yZSM3@mli!28US{T^U a2x1W#&;j18Y#`-~Ko|<7BY_q%FaQ7>DW?7a literal 0 HcmV?d00001 diff --git a/spec/fixtures/download_hcm_corrupt.zip b/spec/fixtures/download_hcm_corrupt.zip new file mode 100644 index 0000000000000000000000000000000000000000..d31e676efb219d13a317904dee98cff3caaa7a4f GIT binary patch literal 300 zcmWIWW@Zs#-~d9dh}dujC}?10U|?rZV8}?$)k`ie3k~6AV4u=xmukfvm4BUNRw^s1>PFhoPty}gYMV8n_>30 zL4Vu7i2u*+rKOdQ+ilv+IH&dbrc(!hZam-UV&!D#|G8w|Q|>+2RBrCRXa0-vO4Qr> zqv2cI&T^U8w{2Lw`Pih#4Zl4C-LBt$(Oz%;-g)`co23gfW`v$A5~*4G_@wNITMvAL zCtRJ%cUC1*@BRBtJlxI?CqI~&y?9~Cu@_;Bm5Q&)b&DTa`Kd;7;fCUEpWmG*?3%