dawarich/spec/services/users/export_data_spec.rb

408 lines
17 KiB
Ruby
Raw Normal View History

2025-06-26 13:24:40 -04:00
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Users::ExportData, type: :service do
let(:user) { create(:user) }
let(:service) { described_class.new(user) }
let(:timestamp) { '20241201_123000' }
let(:export_directory) { Rails.root.join('tmp', "#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_#{timestamp}") }
let(:files_directory) { export_directory.join('files') }
before do
allow(Time).to receive(:current).and_return(Time.new(2024, 12, 1, 12, 30, 0))
allow(FileUtils).to receive(:mkdir_p)
allow(FileUtils).to receive(:rm_rf)
allow(File).to receive(:open).and_call_original
allow(File).to receive(:directory?).and_return(true)
end
describe '#export' do
context 'when export is successful' do
let(:zip_file_path) { export_directory.join('export.zip') }
let(:zip_file_double) { double('ZipFile') }
let(:export_record) { double('Export', id: 1, name: 'test.zip', update!: true, file: double('File', attach: true)) }
let(:notification_service_double) { double('Notifications::Create', call: true) }
before do
# Mock all the export data services
allow(Users::ExportData::Areas).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Imports).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Exports).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Trips).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Stats).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Notifications).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Points).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Visits).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Places).to receive(:new).and_return(double(call: []))
# Mock user settings
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
2025-06-28 06:22:56 -04:00
# Mock user associations for counting (needed before error occurs)
allow(user).to receive(:areas).and_return(double(count: 5))
allow(user).to receive(:imports).and_return(double(count: 12))
allow(user).to receive(:trips).and_return(double(count: 8))
allow(user).to receive(:stats).and_return(double(count: 24))
allow(user).to receive(:notifications).and_return(double(count: 10))
2025-08-22 15:27:50 -04:00
allow(user).to receive(:points_count).and_return(15000)
2025-06-28 06:22:56 -04:00
allow(user).to receive(:visits).and_return(double(count: 45))
allow(user).to receive(:places).and_return(double(count: 20))
2025-06-26 13:24:40 -04:00
# Mock Export creation and file attachment
2025-06-28 06:22:56 -04:00
exports_double = double('Exports', count: 3)
2025-06-26 13:24:40 -04:00
allow(user).to receive(:exports).and_return(exports_double)
allow(exports_double).to receive(:create!).and_return(export_record)
allow(export_record).to receive(:update!)
allow(export_record).to receive_message_chain(:file, :attach)
# Mock Zip file creation
2025-09-26 17:31:45 -04:00
allow(Zip::File).to receive(:open).with(zip_file_path, create: true).and_yield(zip_file_double)
2025-06-26 13:24:40 -04:00
allow(zip_file_double).to receive(:default_compression=)
allow(zip_file_double).to receive(:default_compression_level=)
allow(zip_file_double).to receive(:add)
allow(Dir).to receive(:glob).and_return([export_directory.join('data.json').to_s])
# Mock file operations - return a File instance for the zip file
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_yield(StringIO.new)
zip_file_io = File.new(__FILE__) # Use current file as a placeholder
allow(File).to receive(:open).with(zip_file_path).and_return(zip_file_io)
# Mock notifications service - prevent actual notification creation
allow(service).to receive(:create_success_notification)
# Mock cleanup to verify it's called
allow(service).to receive(:cleanup_temporary_files)
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
end
it 'creates an Export record with correct attributes' do
expect(user.exports).to receive(:create!).with(
name: "user_data_export_#{timestamp}.zip",
file_format: :archive,
file_type: :user_data,
status: :processing
)
service.export
end
it 'creates the export directory structure' do
expect(FileUtils).to receive(:mkdir_p).with(files_directory)
service.export
end
it 'calls all export data services with correct parameters' do
expect(Users::ExportData::Areas).to receive(:new).with(user)
expect(Users::ExportData::Imports).to receive(:new).with(user, files_directory)
expect(Users::ExportData::Exports).to receive(:new).with(user, files_directory)
expect(Users::ExportData::Trips).to receive(:new).with(user)
expect(Users::ExportData::Stats).to receive(:new).with(user)
expect(Users::ExportData::Notifications).to receive(:new).with(user)
expect(Users::ExportData::Points).to receive(:new).with(user)
expect(Users::ExportData::Visits).to receive(:new).with(user)
expect(Users::ExportData::Places).to receive(:new).with(user)
service.export
end
it 'creates a zip file with proper compression settings' do
2025-09-26 17:31:45 -04:00
expect(Zip::File).to receive(:open).with(zip_file_path, create: true)
2025-06-30 14:29:47 -04:00
expect(Zip).to receive(:default_compression).and_return(-1) # Mock original compression
expect(Zip).to receive(:default_compression=).with(Zip::Entry::DEFLATED)
expect(Zip).to receive(:default_compression=).with(-1) # Restoration
2025-06-26 13:24:40 -04:00
service.export
end
it 'attaches the zip file to the export record' do
expect(export_record.file).to receive(:attach).with(
io: an_instance_of(File),
filename: export_record.name,
content_type: 'application/zip'
)
service.export
end
it 'marks the export as completed' do
expect(export_record).to receive(:update!).with(status: :completed)
service.export
end
it 'creates a success notification' do
expect(service).to receive(:create_success_notification)
service.export
end
it 'cleans up temporary files' do
expect(service).to receive(:cleanup_temporary_files).with(export_directory)
service.export
end
it 'returns the export record' do
result = service.export
expect(result).to eq(export_record)
end
2025-06-28 06:22:56 -04:00
it 'calculates entity counts correctly' do
counts = service.send(:calculate_entity_counts)
expect(counts).to eq({
areas: 5,
imports: 12,
exports: 3,
trips: 8,
stats: 24,
notifications: 10,
points: 15000,
visits: 45,
places: 20
})
end
2025-06-26 13:24:40 -04:00
end
context 'when an error occurs during export' do
let(:export_record) { double('Export', id: 1, name: 'test.zip', update!: true) }
let(:error_message) { 'Something went wrong' }
before do
# Mock Export creation first
2025-06-28 06:22:56 -04:00
exports_double = double('Exports', count: 3)
2025-06-26 13:24:40 -04:00
allow(user).to receive(:exports).and_return(exports_double)
allow(exports_double).to receive(:create!).and_return(export_record)
allow(export_record).to receive(:update!)
# Mock user settings and other dependencies that are needed before the error
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
2025-06-28 06:22:56 -04:00
# Mock user associations for counting
allow(user).to receive(:areas).and_return(double(count: 5))
allow(user).to receive(:imports).and_return(double(count: 12))
# exports already mocked above
allow(user).to receive(:trips).and_return(double(count: 8))
allow(user).to receive(:stats).and_return(double(count: 24))
allow(user).to receive(:notifications).and_return(double(count: 10))
2025-08-22 15:27:50 -04:00
allow(user).to receive(:points_count).and_return(15000)
2025-06-28 06:22:56 -04:00
allow(user).to receive(:visits).and_return(double(count: 45))
allow(user).to receive(:places).and_return(double(count: 20))
2025-06-26 13:24:40 -04:00
# Then set up the error condition - make it happen during the JSON writing step
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_raise(StandardError, error_message)
2025-06-28 06:22:56 -04:00
allow(ExceptionReporter).to receive(:call)
2025-06-26 13:24:40 -04:00
# Mock cleanup method and pathname existence
allow(service).to receive(:cleanup_temporary_files)
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
end
it 'marks the export as failed' do
expect(export_record).to receive(:update!).with(status: :failed)
expect { service.export }.to raise_error(StandardError, error_message)
end
2025-06-28 06:22:56 -04:00
it 'reports the error via ExceptionReporter' do
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Export failed')
2025-06-26 13:24:40 -04:00
expect { service.export }.to raise_error(StandardError, error_message)
end
it 'still cleans up temporary files' do
expect(service).to receive(:cleanup_temporary_files)
expect { service.export }.to raise_error(StandardError, error_message)
end
it 're-raises the error' do
expect { service.export }.to raise_error(StandardError, error_message)
end
end
context 'when export record creation fails' do
before do
2025-06-28 06:22:56 -04:00
exports_double = double('Exports', count: 3)
2025-06-26 13:24:40 -04:00
allow(user).to receive(:exports).and_return(exports_double)
allow(exports_double).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
end
it 'does not try to mark export as failed when export_record is nil' do
expect { service.export }.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with file compression scenarios' do
let(:export_record) { double('Export', id: 1, name: 'test.zip', update!: true, file: double('File', attach: true)) }
before do
# Mock Export creation
2025-06-28 06:22:56 -04:00
exports_double = double('Exports', count: 3)
2025-06-26 13:24:40 -04:00
allow(user).to receive(:exports).and_return(exports_double)
allow(exports_double).to receive(:create!).and_return(export_record)
allow(export_record).to receive(:update!)
allow(export_record).to receive_message_chain(:file, :attach)
# Mock all export services to prevent actual calls
allow(Users::ExportData::Areas).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Imports).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Exports).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Trips).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Stats).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Notifications).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Points).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Visits).to receive(:new).and_return(double(call: []))
allow(Users::ExportData::Places).to receive(:new).and_return(double(call: []))
allow(user).to receive(:safe_settings).and_return(double(settings: {}))
2025-06-28 06:22:56 -04:00
# Mock user associations for counting
allow(user).to receive(:areas).and_return(double(count: 5))
allow(user).to receive(:imports).and_return(double(count: 12))
# exports already mocked above
allow(user).to receive(:trips).and_return(double(count: 8))
allow(user).to receive(:stats).and_return(double(count: 24))
allow(user).to receive(:notifications).and_return(double(count: 10))
2025-08-22 15:27:50 -04:00
allow(user).to receive(:points_count).and_return(15000)
2025-06-28 06:22:56 -04:00
allow(user).to receive(:visits).and_return(double(count: 45))
allow(user).to receive(:places).and_return(double(count: 20))
2025-06-26 13:24:40 -04:00
allow(File).to receive(:open).and_call_original
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_yield(StringIO.new)
# Use current file as placeholder for zip file
zip_file_io = File.new(__FILE__)
allow(File).to receive(:open).with(export_directory.join('export.zip')).and_return(zip_file_io)
# Mock notifications service
allow(service).to receive(:create_success_notification)
# Mock cleanup
allow(service).to receive(:cleanup_temporary_files)
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
end
it 'calls create_zip_archive with correct parameters' do
expect(service).to receive(:create_zip_archive).with(export_directory, export_directory.join('export.zip'))
service.export
end
end
end
describe 'private methods' do
describe '#export_directory' do
it 'generates correct directory path' do
allow(Time).to receive_message_chain(:current, :strftime).with('%Y%m%d_%H%M%S').and_return(timestamp)
# Call export to initialize the directory paths
service.instance_variable_set(:@export_directory, Rails.root.join('tmp', "#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_#{timestamp}"))
expect(service.send(:export_directory).to_s).to include(user.email.gsub(/[^0-9A-Za-z._-]/, '_'))
expect(service.send(:export_directory).to_s).to include(timestamp)
end
end
describe '#files_directory' do
it 'returns files subdirectory of export directory' do
# Initialize the export directory first
service.instance_variable_set(:@export_directory, Rails.root.join('tmp', "test_export"))
service.instance_variable_set(:@files_directory, service.instance_variable_get(:@export_directory).join('files'))
files_dir = service.send(:files_directory)
expect(files_dir.to_s).to end_with('files')
end
end
describe '#cleanup_temporary_files' do
context 'when directory exists' do
before do
allow(File).to receive(:directory?).and_return(true)
allow(Rails.logger).to receive(:info)
end
it 'removes the directory' do
expect(FileUtils).to receive(:rm_rf).with(export_directory)
service.send(:cleanup_temporary_files, export_directory)
end
it 'logs the cleanup' do
expect(Rails.logger).to receive(:info).with("Cleaning up temporary export directory: #{export_directory}")
service.send(:cleanup_temporary_files, export_directory)
end
end
context 'when cleanup fails' do
before do
allow(File).to receive(:directory?).and_return(true)
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
2025-06-28 06:22:56 -04:00
allow(ExceptionReporter).to receive(:call)
2025-06-26 13:24:40 -04:00
end
2025-06-28 06:22:56 -04:00
it 'reports the error via ExceptionReporter but does not re-raise' do
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Failed to cleanup temporary files')
2025-06-26 13:24:40 -04:00
expect { service.send(:cleanup_temporary_files, export_directory) }.not_to raise_error
end
end
context 'when directory does not exist' do
before do
allow(File).to receive(:directory?).and_return(false)
end
it 'does not attempt cleanup' do
expect(FileUtils).not_to receive(:rm_rf)
service.send(:cleanup_temporary_files, export_directory)
end
end
end
2025-06-28 06:22:56 -04:00
describe '#calculate_entity_counts' do
before do
# Mock user associations for counting
allow(user).to receive(:areas).and_return(double(count: 5))
allow(user).to receive(:imports).and_return(double(count: 12))
allow(user).to receive(:exports).and_return(double(count: 3))
allow(user).to receive(:trips).and_return(double(count: 8))
allow(user).to receive(:stats).and_return(double(count: 24))
allow(user).to receive(:notifications).and_return(double(count: 10))
2025-08-22 15:27:50 -04:00
allow(user).to receive(:points_count).and_return(15000)
2025-06-28 06:22:56 -04:00
allow(user).to receive(:visits).and_return(double(count: 45))
allow(user).to receive(:places).and_return(double(count: 20))
allow(Rails.logger).to receive(:info)
end
it 'returns correct counts for all entity types' do
counts = service.send(:calculate_entity_counts)
expect(counts).to eq({
areas: 5,
imports: 12,
exports: 3,
trips: 8,
stats: 24,
notifications: 10,
points: 15000,
visits: 45,
places: 20
})
end
it 'logs the calculation process' do
expect(Rails.logger).to receive(:info).with("Calculating entity counts for export")
expect(Rails.logger).to receive(:info).with(/Entity counts:/)
service.send(:calculate_entity_counts)
end
end
2025-06-26 13:24:40 -04:00
end
end