mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Extract file downloader
This commit is contained in:
parent
2e53f39a7f
commit
deeb250910
12 changed files with 249 additions and 232 deletions
|
|
@ -1,31 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::SubscriptionsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :authenticate_non_self_hosted!
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
class Api::V1::SubscriptionsController < ApiController
|
||||
skip_before_action :authenticate_api_key, only: %i[callback]
|
||||
def callback
|
||||
token = params[:token]
|
||||
decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call
|
||||
|
||||
begin
|
||||
decoded_token = Subscription::DecodeJwtToken.new(token).call
|
||||
user = User.find(decoded_token[:user_id])
|
||||
user.update!(status: decoded_token[:status], active_until: decoded_token[:active_until])
|
||||
|
||||
unless decoded_token[:user_id] == current_user.id
|
||||
render json: { message: 'Invalid subscription update request.' }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
current_user.update!(status: decoded_token[:status], active_until: decoded_token[:active_until])
|
||||
|
||||
render json: { message: 'Subscription updated successfully' }
|
||||
rescue JWT::DecodeError => e
|
||||
Sentry.capture_exception(e)
|
||||
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
|
||||
rescue ArgumentError => e
|
||||
Sentry.capture_exception(e)
|
||||
render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity
|
||||
end
|
||||
render json: { message: 'Subscription updated successfully' }
|
||||
rescue JWT::DecodeError => e
|
||||
Sentry.capture_exception(e)
|
||||
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
|
||||
rescue ArgumentError => e
|
||||
Sentry.capture_exception(e)
|
||||
render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ export default class extends BaseController {
|
|||
maps[this.userSettings.maps.name] = customLayer;
|
||||
} else {
|
||||
// If no custom map is set, ensure a default layer is added
|
||||
const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"];
|
||||
const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"] || maps["Atlas"];
|
||||
defaultLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,7 @@
|
|||
|
||||
import BaseController from "./base_controller"
|
||||
import L from "leaflet"
|
||||
import {
|
||||
osmMapLayer,
|
||||
osmHotMapLayer,
|
||||
OPNVMapLayer,
|
||||
openTopoMapLayer,
|
||||
cyclOsmMapLayer,
|
||||
esriWorldStreetMapLayer,
|
||||
esriWorldTopoMapLayer,
|
||||
esriWorldImageryMapLayer,
|
||||
esriWorldGrayCanvasMapLayer
|
||||
} from "../maps/layers"
|
||||
import { createAllMapLayers } from "../maps/layers"
|
||||
import { createPopupContent } from "../maps/popups"
|
||||
import {
|
||||
fetchAndDisplayPhotos,
|
||||
|
|
@ -61,7 +51,10 @@ export default class extends BaseController {
|
|||
this.map = L.map(this.containerTarget).setView(center, zoom)
|
||||
|
||||
// Add base map layer
|
||||
osmMapLayer(this.map, "OpenStreetMap")
|
||||
const selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
|
||||
const maps = this.baseMaps();
|
||||
const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"] || maps["Atlas"];
|
||||
defaultLayer.addTo(this.map);
|
||||
|
||||
// Add scale control to bottom right
|
||||
L.control.scale({
|
||||
|
|
@ -168,18 +161,30 @@ export default class extends BaseController {
|
|||
|
||||
baseMaps() {
|
||||
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName);
|
||||
|
||||
return {
|
||||
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
|
||||
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
|
||||
OPNV: OPNVMapLayer(this.map, selectedLayerName),
|
||||
openTopo: openTopoMapLayer(this.map, selectedLayerName),
|
||||
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
|
||||
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
|
||||
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
|
||||
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
|
||||
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
|
||||
};
|
||||
// Add custom map if it exists in settings
|
||||
if (this.userSettings.maps && this.userSettings.maps.url) {
|
||||
const customLayer = L.tileLayer(this.userSettings.maps.url, {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap contributors"
|
||||
});
|
||||
|
||||
// If this is the preferred layer, add it to the map immediately
|
||||
if (selectedLayerName === this.userSettings.maps.name) {
|
||||
customLayer.addTo(this.map);
|
||||
// Remove any other base layers that might be active
|
||||
Object.values(maps).forEach(layer => {
|
||||
if (this.map.hasLayer(layer)) {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
maps[this.userSettings.maps.name] = customLayer;
|
||||
}
|
||||
|
||||
return maps;
|
||||
}
|
||||
|
||||
addMarkers() {
|
||||
|
|
|
|||
|
|
@ -48,127 +48,3 @@ export function osmMapLayer(map, selectedLayerName) {
|
|||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function osmHotMapLayer(map, selectedLayerName) {
|
||||
let layerName = "OpenStreetMap.HOT";
|
||||
let layer = L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France",
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function OPNVMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'OPNV';
|
||||
let layer = L.tileLayer('https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map <a href="https://memomaps.de/">memomaps.de</a> <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function openTopoMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'openTopo';
|
||||
let layer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 17,
|
||||
attribution: 'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function cyclOsmMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'cyclOsm';
|
||||
let layer = L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', {
|
||||
maxZoom: 20,
|
||||
attribution: '<a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> | Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function esriWorldStreetMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'esriWorldStreet';
|
||||
let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', {
|
||||
minZoom: 1,
|
||||
maxZoom: 19,
|
||||
bounds: [[-90, -180], [90, 180]],
|
||||
noWrap: true,
|
||||
attribution: 'Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function esriWorldTopoMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'esriWorldTopo';
|
||||
let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
|
||||
minZoom: 1,
|
||||
maxZoom: 19,
|
||||
bounds: [[-90, -180], [90, 180]],
|
||||
noWrap: true,
|
||||
attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function esriWorldImageryMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'esriWorldImagery';
|
||||
let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
minZoom: 1,
|
||||
maxZoom: 19,
|
||||
bounds: [[-90, -180], [90, 180]],
|
||||
noWrap: true,
|
||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
export function esriWorldGrayCanvasMapLayer(map, selectedLayerName) {
|
||||
let layerName = 'esriWorldGrayCanvas';
|
||||
let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', {
|
||||
minZoom: 1,
|
||||
maxZoom: 16,
|
||||
bounds: [[-90, -180], [90, 180]],
|
||||
noWrap: true,
|
||||
attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ'
|
||||
});
|
||||
|
||||
if (selectedLayerName === layerName) {
|
||||
return layer.addTo(map);
|
||||
} else {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
class GoogleMaps::PhoneTakeoutParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout
|
||||
MAX_RETRIES = 3
|
||||
|
||||
attr_reader :import, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -48,15 +51,15 @@ class GoogleMaps::PhoneTakeoutParser
|
|||
raw_signals = []
|
||||
raw_array = []
|
||||
|
||||
import.file.download do |file|
|
||||
json = Oj.load(file)
|
||||
file_content = SecureFileDownloader.new(import.file).download_with_verification
|
||||
|
||||
if json.is_a?(Array)
|
||||
raw_array = parse_raw_array(json)
|
||||
else
|
||||
semantic_segments = parse_semantic_segments(json['semanticSegments']) if json['semanticSegments']
|
||||
raw_signals = parse_raw_signals(json['rawSignals']) if json['rawSignals']
|
||||
end
|
||||
json = Oj.load(file_content)
|
||||
|
||||
if json.is_a?(Array)
|
||||
raw_array = parse_raw_array(json)
|
||||
else
|
||||
semantic_segments = parse_semantic_segments(json['semanticSegments']) if json['semanticSegments']
|
||||
raw_signals = parse_raw_signals(json['rawSignals']) if json['rawSignals']
|
||||
end
|
||||
|
||||
semantic_segments + raw_signals + raw_array
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
class GoogleMaps::RecordsStorageImporter
|
||||
BATCH_SIZE = 1000
|
||||
MAX_RETRIES = 3
|
||||
DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout
|
||||
|
||||
def initialize(import, user_id)
|
||||
@import = import
|
||||
|
|
@ -25,54 +23,13 @@ class GoogleMaps::RecordsStorageImporter
|
|||
attr_reader :import, :user
|
||||
|
||||
def process_file_in_batches
|
||||
file = download_file
|
||||
verify_file_integrity(file)
|
||||
locations = parse_file(file)
|
||||
file_content = SecureFileDownloader.new(import.file).download_with_verification
|
||||
locations = parse_file(file_content)
|
||||
process_locations_in_batches(locations) if locations.present?
|
||||
end
|
||||
|
||||
def download_file
|
||||
retries = 0
|
||||
|
||||
begin
|
||||
Timeout.timeout(DOWNLOAD_TIMEOUT) do
|
||||
import.file.download
|
||||
end
|
||||
rescue Timeout::Error => e
|
||||
retries += 1
|
||||
if retries <= MAX_RETRIES
|
||||
Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}")
|
||||
retry
|
||||
else
|
||||
Rails.logger.error("Download failed after #{MAX_RETRIES} attempts")
|
||||
raise
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Download error: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def verify_file_integrity(file)
|
||||
# Verify file size
|
||||
expected_size = import.file.blob.byte_size
|
||||
actual_size = file.size
|
||||
|
||||
if expected_size != actual_size
|
||||
raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes"
|
||||
end
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = import.file.blob.checksum
|
||||
actual_checksum = Base64.strict_encode64(Digest::MD5.digest(file))
|
||||
|
||||
return unless expected_checksum != actual_checksum
|
||||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
|
||||
def parse_file(file)
|
||||
parsed_file = Oj.load(file, mode: :compat)
|
||||
def parse_file(file_content)
|
||||
parsed_file = Oj.load(file_content, mode: :compat)
|
||||
return nil unless parsed_file.is_a?(Hash) && parsed_file['locations']
|
||||
|
||||
parsed_file['locations']
|
||||
|
|
|
|||
81
app/services/secure_file_downloader.rb
Normal file
81
app/services/secure_file_downloader.rb
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SecureFileDownloader
|
||||
DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout
|
||||
MAX_RETRIES = 3
|
||||
|
||||
def initialize(storage_attachment)
|
||||
@storage_attachment = storage_attachment
|
||||
end
|
||||
|
||||
def download_with_verification
|
||||
retries = 0
|
||||
file_content = nil
|
||||
|
||||
begin
|
||||
Timeout.timeout(DOWNLOAD_TIMEOUT) do
|
||||
# Download the file to a string
|
||||
tempfile = Tempfile.new("download_#{Time.now.to_i}")
|
||||
begin
|
||||
# Try to download block-by-block
|
||||
storage_attachment.download do |chunk|
|
||||
tempfile.write(chunk)
|
||||
end
|
||||
tempfile.rewind
|
||||
file_content = tempfile.read
|
||||
ensure
|
||||
tempfile.close
|
||||
tempfile.unlink
|
||||
end
|
||||
|
||||
# If we didn't get any content but no error occurred, try a different approach
|
||||
if file_content.nil? || file_content.empty?
|
||||
Rails.logger.warn('No content received from block download, trying alternative method')
|
||||
# Some ActiveStorage attachments may work differently, try direct access if possible
|
||||
file_content = storage_attachment.blob.download
|
||||
end
|
||||
end
|
||||
rescue Timeout::Error => e
|
||||
retries += 1
|
||||
if retries <= MAX_RETRIES
|
||||
Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}")
|
||||
retry
|
||||
else
|
||||
Rails.logger.error("Download failed after #{MAX_RETRIES} attempts")
|
||||
raise
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Download error: #{e.message}")
|
||||
raise
|
||||
end
|
||||
|
||||
raise 'Download completed but no content was received' if file_content.nil? || file_content.empty?
|
||||
|
||||
verify_file_integrity(file_content)
|
||||
file_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :storage_attachment
|
||||
|
||||
def verify_file_integrity(file_content)
|
||||
return if file_content.nil? || file_content.empty?
|
||||
|
||||
# Verify file size
|
||||
expected_size = storage_attachment.blob.byte_size
|
||||
actual_size = file_content.bytesize
|
||||
|
||||
if expected_size != actual_size
|
||||
raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes"
|
||||
end
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = storage_attachment.blob.checksum
|
||||
actual_checksum = Base64.strict_encode64(Digest::MD5.digest(file_content))
|
||||
|
||||
return unless expected_checksum != actual_checksum
|
||||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
end
|
||||
|
|
@ -9,7 +9,7 @@ class Subscription::DecodeJwtToken
|
|||
# @return [Visit, nil] The merged visit or nil if merge failed
|
||||
def call
|
||||
JWT.decode(
|
||||
token,
|
||||
@token,
|
||||
ENV['JWT_SECRET_KEY'],
|
||||
true,
|
||||
{ algorithm: 'HS256' }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@
|
|||
<% end %>
|
||||
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %>
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<%= link_to 'Subscriptions', settings_subscriptions_path, role: 'tab', class: "tab #{active_tab?(settings_subscriptions_path)}" %>
|
||||
<%= link_to 'Subscriptions', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", role: 'tab', class: "tab" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<%= link_to 'Dawarich', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<%= link_to 'Dawarich<sup>α</sup>'.html_safe, root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<div class="badge mx-4 <%= 'badge-outline' if new_version_available? %>">
|
||||
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
|
||||
<% if new_version_available? %>
|
||||
|
|
|
|||
|
|
@ -146,9 +146,17 @@ RSpec.describe GoogleMaps::RecordsStorageImporter do
|
|||
context 'with download issues' do
|
||||
it 'retries on timeout' do
|
||||
call_count = 0
|
||||
allow(import.file).to receive(:download) do
|
||||
|
||||
# Mock the SecureFileDownloader instead of the file's download method
|
||||
mock_downloader = instance_double(SecureFileDownloader)
|
||||
allow(SecureFileDownloader).to receive(:new).and_return(mock_downloader)
|
||||
|
||||
# Set up the mock to raise timeout twice then return content
|
||||
allow(mock_downloader).to receive(:download_with_verification) do
|
||||
call_count += 1
|
||||
call_count < 3 ? raise(Timeout::Error) : file_content
|
||||
raise Timeout::Error if call_count < 3
|
||||
|
||||
file_content
|
||||
end
|
||||
|
||||
expect(Rails.logger).to receive(:warn).twice
|
||||
|
|
|
|||
99
spec/services/secure_file_downloader_spec.rb
Normal file
99
spec/services/secure_file_downloader_spec.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SecureFileDownloader do
|
||||
let(:file_content) { 'test content' }
|
||||
let(:file_size) { file_content.bytesize }
|
||||
let(:checksum) { Base64.strict_encode64(Digest::MD5.digest(file_content)) }
|
||||
let(:blob) { double('ActiveStorage::Blob', byte_size: file_size, checksum: checksum) }
|
||||
# Create a mock that mimics ActiveStorage::Attached::One
|
||||
let(:storage_attachment) { double('ActiveStorage::Attached::One', blob: blob) }
|
||||
|
||||
subject { described_class.new(storage_attachment) }
|
||||
|
||||
describe '#download_with_verification' do
|
||||
context 'when download is successful' do
|
||||
before do
|
||||
# Mock the download method to yield the file content
|
||||
allow(storage_attachment).to receive(:download) do |&block|
|
||||
block.call(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns the file content' do
|
||||
expect(subject.download_with_verification).to eq(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timeout occurs but succeeds on retry' do
|
||||
before do
|
||||
call_count = 0
|
||||
allow(storage_attachment).to receive(:download) do |&block|
|
||||
call_count += 1
|
||||
raise Timeout::Error if call_count == 1
|
||||
|
||||
block.call(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
it 'retries the download and returns the file content' do
|
||||
expect(Rails.logger).to receive(:warn).with(/Download timeout, attempt 1 of/)
|
||||
expect(subject.download_with_verification).to eq(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all download attempts timeout' do
|
||||
before do
|
||||
allow(storage_attachment).to receive(:download).and_raise(Timeout::Error)
|
||||
end
|
||||
|
||||
it 'raises an error after max retries' do
|
||||
described_class::MAX_RETRIES.times do |i|
|
||||
expect(Rails.logger).to receive(:warn).with(/Download timeout, attempt #{i + 1} of/)
|
||||
end
|
||||
expect(Rails.logger).to receive(:error).with(/Download failed after/)
|
||||
expect { subject.download_with_verification }.to raise_error(Timeout::Error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when file size does not match' do
|
||||
let(:blob) { double('ActiveStorage::Blob', byte_size: 100, checksum: checksum) }
|
||||
|
||||
before do
|
||||
allow(storage_attachment).to receive(:download) do |&block|
|
||||
block.call(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject.download_with_verification }.to raise_error(/Incomplete download/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when checksum does not match' do
|
||||
let(:blob) { double('ActiveStorage::Blob', byte_size: file_size, checksum: 'invalid_checksum') }
|
||||
|
||||
before do
|
||||
allow(storage_attachment).to receive(:download) do |&block|
|
||||
block.call(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject.download_with_verification }.to raise_error(/Checksum mismatch/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when download fails with a different error' do
|
||||
before do
|
||||
allow(storage_attachment).to receive(:download).and_raise(StandardError, 'Download failed')
|
||||
end
|
||||
|
||||
it 'logs the error and re-raises it' do
|
||||
expect(Rails.logger).to receive(:error).with(/Download error: Download failed/)
|
||||
expect { subject.download_with_verification }.to raise_error(StandardError, 'Download failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue