このコミットが含まれているのは:
@@ -14,7 +14,7 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
||||
allow(CountsExportDownload).to receive(:directory).and_return(@counts_download_dir)
|
||||
end
|
||||
|
||||
def ready_job(format)
|
||||
def ready_job(format, payload: 'payload')
|
||||
CountsExportJobs.pulse
|
||||
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
|
||||
mode: 'details', columns: 'resource_id', locale: 'ja', format: }
|
||||
@@ -22,7 +22,7 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
||||
job = response.parsed_body.fetch('job')
|
||||
signature = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job')
|
||||
owner = signature.fetch('owner')
|
||||
exporter = instance_double(CountsExporter, resource: 'posts', format:, body: ['payload'])
|
||||
exporter = instance_double(CountsExporter, resource: 'posts', format:, body: [payload])
|
||||
file = HistoryAccess.set(user: nil) { CountsExportDownload.prepare(exporter, owner) }
|
||||
state = CountsExportJobs.read(signature.fetch('id'))
|
||||
state.merge!('status' => 'ready', 'token' => file)
|
||||
@@ -33,6 +33,28 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
||||
end
|
||||
|
||||
['csv', 'json'].each do |format|
|
||||
it "delivers every byte of a multi-chunk #{ format } file through HTTP" do
|
||||
sign_in_as(create(:user, :member))
|
||||
rows = Array.new(5000) { |index| { id: index, name: '日本語,名称' } }
|
||||
payload = if format == 'json'
|
||||
rows.to_json
|
||||
else
|
||||
"id,name\r\n" + rows.map { CSV.generate_line(_1.values) }.join
|
||||
end
|
||||
job, file, attempt = ready_job(format, payload:)
|
||||
owner = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job').fetch('owner')
|
||||
metadata = HistoryAccess.set(user: nil) { CountsExportDownload.find(file, owner) }
|
||||
expected = File.binread(metadata.fetch('path'))
|
||||
expect(expected.bytesize).to be > 64 * 1024
|
||||
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body.bytesize).to eq(expected.bytesize)
|
||||
expect(response.body.b).to eq(expected)
|
||||
expect(response.body.b).to eq(payload.b)
|
||||
get "/counts/export/jobs/#{ job }"
|
||||
expect(response.parsed_body['download_status']).to eq('started')
|
||||
end
|
||||
|
||||
it "acknowledges only an authorised #{ format } GET body, not HEAD or acceptance" do
|
||||
job, file, attempt = ready_job(format)
|
||||
get "/counts/export/jobs/#{ job }"
|
||||
|
||||
@@ -50,6 +50,32 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
|
||||
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
|
||||
|
||||
it 'identifies a totals timeout without exposing SQL in the error response' do
|
||||
allow_any_instance_of(CountsPivotTotals).to receive(:call)
|
||||
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
|
||||
get '/counts/query', params: base.merge(mode: 'summary', rows: 'created_at:month')
|
||||
expect(response).to have_http_status(:service_unavailable)
|
||||
expect(response.parsed_body).to eq('error' => 'counts_query_timeout',
|
||||
'stage' => 'totals', 'timeout_seconds' => 30)
|
||||
expect(response.body).not_to include('private SQL')
|
||||
end
|
||||
|
||||
it 'applies the same optimizer safeguards to the paginated detail SELECT' do
|
||||
statements = []
|
||||
listener = lambda do |_name, _start, _finish, _id, payload|
|
||||
statements << payload[:sql]
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
|
||||
result = query(tags: 'alpha', limit: 1)
|
||||
expect(result['count']).to eq(2)
|
||||
expect(versions(result)).to eq([1])
|
||||
end
|
||||
details = statements.find { _1.include?('AS counts_latest') }
|
||||
expect(details).not_to be_nil
|
||||
expect(details).to include('MAX_EXECUTION_TIME(30000)',
|
||||
"SET_VAR(optimizer_switch='semijoin=off')")
|
||||
end
|
||||
|
||||
it 'separates latest and history and defaults to stable detail ordering and limit 100' do
|
||||
history = query
|
||||
expect(versions(history)).to eq([1, 2, 3])
|
||||
|
||||
@@ -211,18 +211,37 @@ RSpec.describe 'Counts download acknowledgement' do
|
||||
end
|
||||
|
||||
RSpec.describe CountsExportDownload::ResponseBody do
|
||||
it 'survives Rails buffer enumeration without closing before the first byte' do
|
||||
Dir.mktmpdir('counts-body-spec') do |directory|
|
||||
path = File.join(directory, 'file')
|
||||
payload = 'data' * 20_000
|
||||
File.binwrite(path, payload)
|
||||
states = []
|
||||
source = ActionDispatch::Response::FileBody.new(path)
|
||||
body = described_class.new(source) { |state| states << state; true }
|
||||
response = ActionDispatch::Response.new
|
||||
response.body = body
|
||||
deferred = response.stream.to_ary
|
||||
expect(states).to eq([])
|
||||
expect(deferred.to_a.join).to eq(payload)
|
||||
expect(states.count('started')).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'acknowledges only after the first Rack yield and closes its bounded file body' do
|
||||
Dir.mktmpdir('counts-body-spec') do |directory|
|
||||
path = File.join(directory, 'file')
|
||||
File.binwrite(path, 'x' * (64 * 1024 + 3))
|
||||
states = []
|
||||
body = described_class.new(path) { |state| states << state; true }
|
||||
source = ActionDispatch::Response::FileBody.new(path)
|
||||
body = described_class.new(source) { |state| states << state; true }
|
||||
chunks = []
|
||||
body.each do |chunk|
|
||||
expect(states).not_to include('started') if chunks.empty?
|
||||
chunks << chunk
|
||||
end
|
||||
expect(chunks.map(&:bytesize)).to eq([64 * 1024, 3])
|
||||
expect(chunks.join).to eq('x' * (64 * 1024 + 3))
|
||||
expect(chunks.map(&:bytesize).max).to be <= 64 * 1024
|
||||
expect(states.count('started')).to eq(1)
|
||||
expect(body).not_to respond_to(:to_path)
|
||||
expect { body.close }.not_to raise_error
|
||||
@@ -234,7 +253,8 @@ RSpec.describe CountsExportDownload::ResponseBody do
|
||||
path = File.join(directory, 'file')
|
||||
File.binwrite(path, 'data')
|
||||
states = []
|
||||
body = described_class.new(path) { |state| states << state; true }
|
||||
source = ActionDispatch::Response::FileBody.new(path)
|
||||
body = described_class.new(source) { |state| states << state; true }
|
||||
expect { body.each { raise IOError, 'connection closed' } }.to raise_error(IOError)
|
||||
expect(states).to include('failed')
|
||||
expect(states).not_to include('started')
|
||||
|
||||
@@ -44,6 +44,20 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
result.fetch(:pivot_totals).find { _1[:row].empty? && _1[:column].empty? }.fetch(:count)
|
||||
end
|
||||
|
||||
it 'preserves monthly name subtotals and grand totals across pages' do
|
||||
settings = { 'resource' => 'tags', 'rows' => 'created_at:month name_ja' }
|
||||
complete = result(admin, settings)
|
||||
paged = result(admin, settings.merge('limit' => '1'))
|
||||
expect(complete[:group_count]).to eq(3)
|
||||
expect(grand(complete)).to eq(3)
|
||||
expect(grand(paged)).to eq(3)
|
||||
subtotal = paged[:pivot_totals].find do |cell|
|
||||
cell[:row] == ['2023-01'] && cell[:column].empty?
|
||||
end
|
||||
expect(subtotal[:count]).to eq(3)
|
||||
expect(paged[:cells].sum { _1[:count] }).to eq(1)
|
||||
end
|
||||
|
||||
it 'preserves five tag occurrences after merging private tags and across pages' do
|
||||
[nil, guest, member, admin].each do |user|
|
||||
page = result(user)
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする