このコミットが含まれているのは:
@@ -12,6 +12,15 @@ class CountsExportsController < ApplicationController
|
|||||||
render json: { error: 'export_worker_unavailable' }, status: :service_unavailable
|
render json: { error: 'export_worker_unavailable' }, status: :service_unavailable
|
||||||
rescue CountsExportDownload::Busy
|
rescue CountsExportDownload::Busy
|
||||||
head :too_many_requests
|
head :too_many_requests
|
||||||
|
rescue CountsAxes::TooManyAxes
|
||||||
|
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
|
||||||
|
rescue CountsAxes::PrivateCondition
|
||||||
|
render json: { error: 'private_condition' }, status: :forbidden
|
||||||
|
rescue CountsTableQuery::TooManyColumns
|
||||||
|
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
rescue ActiveRecord::StatementTimeout
|
||||||
|
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
@@ -48,6 +57,15 @@ class CountsExportsController < ApplicationController
|
|||||||
head :gateway_timeout
|
head :gateway_timeout
|
||||||
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
|
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
|
||||||
head :insufficient_storage
|
head :insufficient_storage
|
||||||
|
rescue CountsAxes::TooManyAxes
|
||||||
|
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
|
||||||
|
rescue CountsAxes::PrivateCondition
|
||||||
|
render json: { error: 'private_condition' }, status: :forbidden
|
||||||
|
rescue CountsTableQuery::TooManyColumns
|
||||||
|
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
rescue ActiveRecord::StatementTimeout
|
||||||
|
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
@@ -79,36 +97,43 @@ class CountsExportsController < ApplicationController
|
|||||||
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
|
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
|
||||||
return head :not_found
|
return head :not_found
|
||||||
end
|
end
|
||||||
# Open before committing the response, but never buffer the entire export.
|
job = params[:job]
|
||||||
stream = File.open(file.fetch('path'), 'rb')
|
authority = HistoryAccess.user
|
||||||
|
first_check = true
|
||||||
|
stream = CountsExportDownload::ResponseBody.new(file.fetch('path')) do |status|
|
||||||
|
if status == 'active'
|
||||||
|
if first_check
|
||||||
|
first_check = false
|
||||||
|
fresh = authority && User.find_by(id: authority.id)
|
||||||
|
next false if authority && (!fresh || fresh.banned?)
|
||||||
|
|
||||||
|
authority = fresh
|
||||||
|
HistoryAccess.set(user: authority) do
|
||||||
|
CountsExportDownload.find(arguments[:file_token], owner) &&
|
||||||
|
CountsExportJobs.stream_active?(job, owner, **arguments)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
CountsExportJobs.stream_active?(job, owner, **arguments)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
HistoryAccess.set(user: authority) do
|
||||||
|
CountsExportJobs.download_attempt(job, owner, **arguments, status:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
unless CountsExportJobs.download_attempt(job, owner, **arguments, status: 'streaming')
|
||||||
|
stream.close
|
||||||
|
return head :conflict
|
||||||
|
end
|
||||||
response.headers['Cache-Control'] = 'private, no-store'
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
send_file_headers! filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
|
send_file_headers! filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
|
||||||
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
|
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
|
||||||
disposition: 'attachment'
|
disposition: 'attachment'
|
||||||
job = params[:job]
|
response.headers['Last-Modified'] = File.mtime(file.fetch('path')).httpdate
|
||||||
authority = HistoryAccess.user
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
self.response_body = Enumerator.new do |output|
|
self.response_body = stream
|
||||||
begin
|
|
||||||
first = true
|
|
||||||
while (chunk = stream.read(64 * 1024))
|
|
||||||
output << chunk
|
|
||||||
if first
|
|
||||||
HistoryAccess.set(user: authority) do
|
|
||||||
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'started')
|
|
||||||
end
|
|
||||||
first = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue IOError, SystemCallError => error
|
|
||||||
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
|
|
||||||
HistoryAccess.set(user: authority) do
|
|
||||||
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'failed')
|
|
||||||
end
|
|
||||||
ensure
|
|
||||||
stream.close
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue IOError, SystemCallError
|
rescue IOError, SystemCallError
|
||||||
|
stream&.close
|
||||||
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
|
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
|
||||||
head :service_unavailable
|
head :service_unavailable
|
||||||
end
|
end
|
||||||
@@ -138,6 +163,15 @@ class CountsExportsController < ApplicationController
|
|||||||
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
|
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
|
||||||
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
|
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
|
||||||
disposition: 'attachment'
|
disposition: 'attachment'
|
||||||
|
rescue CountsAxes::TooManyAxes
|
||||||
|
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
|
||||||
|
rescue CountsAxes::PrivateCondition
|
||||||
|
render json: { error: 'private_condition' }, status: :forbidden
|
||||||
|
rescue CountsTableQuery::TooManyColumns
|
||||||
|
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
rescue ActiveRecord::StatementTimeout
|
||||||
|
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ require 'digest'
|
|||||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||||
attribute :user
|
attribute :user
|
||||||
class Forbidden < StandardError; end
|
class Forbidden < StandardError; end
|
||||||
|
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
||||||
|
|
||||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||||
'nico_tag_versions' => ['tag_id'],
|
'nico_tag_versions' => ['tag_id'],
|
||||||
@@ -18,10 +19,12 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.authority
|
def self.authority
|
||||||
{ 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id }
|
{ 'artifact_policy' => ARTIFACT_POLICY, 'policy_revision' => revision,
|
||||||
|
'admin' => admin?, 'actor_id' => user&.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.authorised_artifact?(metadata)
|
def self.authorised_artifact?(metadata)
|
||||||
|
return false unless metadata['artifact_policy'] == ARTIFACT_POLICY
|
||||||
return false unless metadata['policy_revision'].is_a?(String)
|
return false unless metadata['policy_revision'].is_a?(String)
|
||||||
if metadata['admin']
|
if metadata['admin']
|
||||||
admin? && user.id == metadata['actor_id']
|
admin? && user.id == metadata['actor_id']
|
||||||
@@ -72,13 +75,47 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
|
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.snapshot_reference(model, row, external: false)
|
||||||
|
key = external ? 'external_tag_id' : 'tag_id'
|
||||||
|
table = model.table_name
|
||||||
|
source = "JSON_TABLE(COALESCE(#{ row }.tags_json, JSON_ARRAY()), '$[*]' COLUMNS (" +
|
||||||
|
"tag_id BIGINT PATH '$.#{ key }', version_no INT PATH '$.version_no')) tag_reference"
|
||||||
|
match = 'reference_version.tag_id = tag_reference.tag_id'
|
||||||
|
if external
|
||||||
|
match += " AND reference_version.created_at <= #{ row }.created_at"
|
||||||
|
latest = "AND NOT EXISTS (SELECT 1 FROM #{ table } later_reference " +
|
||||||
|
'WHERE later_reference.tag_id = reference_version.tag_id ' +
|
||||||
|
"AND later_reference.created_at <= #{ row }.created_at " +
|
||||||
|
'AND later_reference.version_no > reference_version.version_no)'
|
||||||
|
else
|
||||||
|
match += ' AND reference_version.version_no = tag_reference.version_no'
|
||||||
|
latest = ''
|
||||||
|
end
|
||||||
|
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
|
||||||
|
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
|
||||||
|
end
|
||||||
|
|
||||||
def self.reference_sql(table, row)
|
def self.reference_sql(table, row)
|
||||||
case table
|
case table
|
||||||
when 'post_versions'
|
when 'post_versions'
|
||||||
[['tag_versions', 'tag_id', 'tag_id'],
|
[['tag_versions', 'tag_id', 'tag_id'],
|
||||||
['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field|
|
['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field|
|
||||||
json_reference(target, key, "JSON_EXTRACT(#{ row }.tags_json, '$[*].#{ field }')")
|
json_reference(target, key, "JSON_EXTRACT(#{ row }.tags_json, '$[*].#{ field }')")
|
||||||
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids")]
|
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids"),
|
||||||
|
snapshot_reference(TagVersion, row),
|
||||||
|
snapshot_reference(NicoTagVersion, row, external: true)]
|
||||||
|
when 'nico_tag_versions'
|
||||||
|
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
|
||||||
|
# conservatively; never expose a private linked name through an external tag.
|
||||||
|
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
|
||||||
|
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
|
||||||
|
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
|
||||||
|
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
|
||||||
|
"ON blocked.tag_id = private_tag.tag_id " +
|
||||||
|
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
|
||||||
|
"WHERE private_name.name <> '' AND " +
|
||||||
|
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
|
||||||
|
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
|
||||||
when 'tag_versions'
|
when 'tag_versions'
|
||||||
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
|
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
|
||||||
when 'tag_description_versions'
|
when 'tag_description_versions'
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ class CountsAxes
|
|||||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||||
end
|
end
|
||||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
||||||
expression = "CASE WHEN #{ HistoryAccess.sql(@definition.model, 'v') } " +
|
visible = HistoryAccess.sql(@definition.model, 'v')
|
||||||
|
if field == 'changed_field'
|
||||||
|
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
||||||
|
end
|
||||||
|
expression = "CASE WHEN #{ visible } " +
|
||||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
||||||
end
|
end
|
||||||
expression
|
expression
|
||||||
@@ -72,8 +76,10 @@ class CountsAxes
|
|||||||
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
|
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
|
||||||
|
|
||||||
# Keep tag occurrences even when several private tags share one masked label.
|
# Keep tag occurrences even when several private tags share one masked label.
|
||||||
"COUNT(DISTINCT v.id, COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
values = ['v.id', "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
"CONCAT('external:', tag_entry.external_tag_id)))"
|
"CONCAT('external:', tag_entry.external_tag_id))"]
|
||||||
|
values << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||||
|
"COUNT(DISTINCT #{ values.join(', ') })"
|
||||||
end
|
end
|
||||||
|
|
||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
|
|||||||
@@ -13,6 +13,46 @@ class CountsExportDownload
|
|||||||
class TimedOut < StandardError; end
|
class TimedOut < StandardError; end
|
||||||
class StorageFull < StandardError; end
|
class StorageFull < StandardError; end
|
||||||
|
|
||||||
|
# Rack body deliberately has no to_path: sendfile middleware must not bypass ACK.
|
||||||
|
class ResponseBody
|
||||||
|
def initialize(path, &state)
|
||||||
|
File.open(path, 'rb') { |file| file.stat }
|
||||||
|
@path = path
|
||||||
|
@stream = nil
|
||||||
|
@closed = false
|
||||||
|
@state = state
|
||||||
|
end
|
||||||
|
|
||||||
|
def each
|
||||||
|
return if @closed
|
||||||
|
|
||||||
|
@stream = File.open(@path, 'rb')
|
||||||
|
first = true
|
||||||
|
while (chunk = @stream.read(64 * 1024))
|
||||||
|
raise IOError, 'Download no longer authorised' unless @state.call('active')
|
||||||
|
|
||||||
|
yield chunk
|
||||||
|
if first
|
||||||
|
# This confirms yielding to Rack, never browser receipt or saving.
|
||||||
|
@state.call('started')
|
||||||
|
first = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@state.call('failed') if first
|
||||||
|
rescue StandardError => error
|
||||||
|
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
|
||||||
|
@state.call('failed')
|
||||||
|
raise
|
||||||
|
ensure
|
||||||
|
close
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
@closed = true
|
||||||
|
@stream.close if @stream && !@stream.closed?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.directory
|
def self.directory
|
||||||
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
|
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ class CountsExportJobs
|
|||||||
write(state)
|
write(state)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.failure_code(error)
|
||||||
|
case error
|
||||||
|
when Cancelled then 'cancelled'
|
||||||
|
when CountsAxes::TooManyAxes then 'too_many_axes'
|
||||||
|
when CountsAxes::PrivateCondition then 'private_condition'
|
||||||
|
when CountsTableQuery::TooManyColumns then 'too_many_columns'
|
||||||
|
when ActiveRecord::StatementTimeout then 'counts_query_timeout'
|
||||||
|
when CountsExportDownload::TimedOut then 'generation_timeout'
|
||||||
|
when CountsExportDownload::TooLarge then 'export_too_large'
|
||||||
|
when CountsExportDownload::StorageFull, Errno::ENOSPC then 'export_storage_full'
|
||||||
|
else 'generation_failed'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.directory = CountsExportDownload.directory.join('jobs')
|
def self.directory = CountsExportDownload.directory.join('jobs')
|
||||||
def self.verifier = Rails.application.message_verifier('counts_export_job')
|
def self.verifier = Rails.application.message_verifier('counts_export_job')
|
||||||
|
|
||||||
@@ -176,6 +190,12 @@ class CountsExportJobs
|
|||||||
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
|
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
|
||||||
|
|
||||||
expire_stalled(state)
|
expire_stalled(state)
|
||||||
|
if (state['status'] == 'ready' &&
|
||||||
|
['pending', 'streaming'].include?(state['download_status']) &&
|
||||||
|
state.fetch('download_expires_at', 0) <= Time.current.to_f)
|
||||||
|
state['download_status'] = 'timeout'
|
||||||
|
write(state)
|
||||||
|
end
|
||||||
|
|
||||||
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
|
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
|
||||||
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
||||||
@@ -183,8 +203,11 @@ class CountsExportJobs
|
|||||||
state.delete('token')
|
state.delete('token')
|
||||||
write(state)
|
write(state)
|
||||||
end
|
end
|
||||||
state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
|
result = state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
|
||||||
'updated_at', 'heartbeat_at', 'download_attempt', 'download_status')
|
'updated_at', 'heartbeat_at', 'download_attempt', 'download_status')
|
||||||
|
result['max_axes'] = CountsAxes::MAX_AXES if state['error'] == 'too_many_axes'
|
||||||
|
result['max_columns'] = CountsTableQuery::MAX_COLUMNS if state['error'] == 'too_many_columns'
|
||||||
|
result
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -198,18 +221,53 @@ class CountsExportJobs
|
|||||||
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
|
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
|
||||||
if attempt
|
if attempt
|
||||||
return unless state['download_attempt'] == attempt && state['token'] == file_token
|
return unless state['download_attempt'] == attempt && state['token'] == file_token
|
||||||
return unless ['pending', 'started'].include?(state['download_status'])
|
return unless state['download_status'] == 'started' ||
|
||||||
|
state.fetch('download_expires_at', 0) > Time.current.to_f
|
||||||
|
|
||||||
state['download_status'] = status if status
|
transitions = { 'streaming' => ['pending'], 'started' => ['streaming'],
|
||||||
|
'failed' => ['pending', 'streaming', 'started'],
|
||||||
|
'unavailable' => ['pending', 'streaming'] }
|
||||||
|
if status == 'active'
|
||||||
|
return state.slice('download_attempt', 'download_status', 'token') if
|
||||||
|
['streaming', 'started'].include?(state['download_status'])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if status
|
||||||
|
return unless transitions.fetch(status, []).include?(state['download_status'])
|
||||||
|
|
||||||
|
state['download_status'] = status
|
||||||
|
else
|
||||||
|
return unless state['download_status'] == 'pending'
|
||||||
|
end
|
||||||
else
|
else
|
||||||
|
# Repeated acceptance POSTs do not invalidate an in-flight GET or issue duplicates.
|
||||||
|
if (state['download_status'] == 'started' ||
|
||||||
|
(['pending', 'streaming'].include?(state['download_status']) &&
|
||||||
|
state.fetch('download_expires_at', 0) > Time.current.to_f))
|
||||||
|
return state.slice('download_attempt', 'download_status', 'token')
|
||||||
|
end
|
||||||
state['download_attempt'] = SecureRandom.hex(24)
|
state['download_attempt'] = SecureRandom.hex(24)
|
||||||
state['download_status'] = 'pending'
|
state['download_status'] = 'pending'
|
||||||
|
state['download_expires_at'] = Time.current.to_f + 45
|
||||||
end
|
end
|
||||||
write(state)
|
write(state)
|
||||||
state.slice('download_attempt', 'download_status', 'token')
|
state.slice('download_attempt', 'download_status', 'token')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET already verified authority. During streaming, observe cancellation without
|
||||||
|
# repeating the six-table policy revision aggregate for every 64KiB chunk.
|
||||||
|
def self.stream_active?(token, owner, file_token:, attempt:)
|
||||||
|
id = authorised_id(token, owner)
|
||||||
|
state = id && read(id)
|
||||||
|
state && state['owner'] == owner && state['status'] == 'ready' &&
|
||||||
|
state['expires_at'] > Time.current.to_f && state['token'] == file_token &&
|
||||||
|
state['download_attempt'] == attempt &&
|
||||||
|
(state['download_status'] == 'started' ||
|
||||||
|
(state['download_status'] == 'streaming' &&
|
||||||
|
state.fetch('download_expires_at', 0) > Time.current.to_f))
|
||||||
|
end
|
||||||
|
|
||||||
def self.checkpoint(id, processed = nil, total = nil, phase: nil)
|
def self.checkpoint(id, processed = nil, total = nil, phase: nil)
|
||||||
synchronise do
|
synchronise do
|
||||||
state = read(id)
|
state = read(id)
|
||||||
@@ -398,7 +456,7 @@ class CountsExportJobs
|
|||||||
if current
|
if current
|
||||||
unless current['status'] == 'failed'
|
unless current['status'] == 'failed'
|
||||||
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
|
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
|
||||||
current['error'] = error.class.name
|
current['error'] = failure_code(error)
|
||||||
end
|
end
|
||||||
current['updated_at'] = Time.current.to_f
|
current['updated_at'] = Time.current.to_f
|
||||||
write(current)
|
write(current)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
|
# Re-aggregate distinct membership; tag row tuples alone use additive contributions.
|
||||||
class CountsPivotTotals
|
class CountsPivotTotals
|
||||||
def initialize(population, axes, row_headers, column_headers)
|
def initialize(population, axes, row_headers, column_headers)
|
||||||
@population = population
|
@population = population
|
||||||
@@ -8,8 +8,12 @@ class CountsPivotTotals
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
# MySQL's unlimited LIMIT prevents CTE merging: authorise once for all branches.
|
# Materialise full-cell membership once, including omitted multi-value axes.
|
||||||
basis = @population.scope.except(:select, :order).select('v.*')
|
membership = @population.scope.except(:select, :order)
|
||||||
|
joins = @axes.joins
|
||||||
|
membership = membership.joins(joins) unless joins.empty?
|
||||||
|
# Retain exactly the records contributing a full cell, including changed_field.
|
||||||
|
basis = membership.select('v.*').distinct
|
||||||
.limit(18_446_744_073_709_551_615).to_sql
|
.limit(18_446_744_073_709_551_615).to_sql
|
||||||
queries = @rows.flat_map do |row_depth, headers|
|
queries = @rows.flat_map do |row_depth, headers|
|
||||||
@columns.filter_map do |column_depth, columns|
|
@columns.filter_map do |column_depth, columns|
|
||||||
@@ -18,7 +22,7 @@ class CountsPivotTotals
|
|||||||
indexes = (0...row_depth).to_a +
|
indexes = (0...row_depth).to_a +
|
||||||
(0...column_depth).map { @axes.rows.length + _1 }
|
(0...column_depth).map { @axes.rows.length + _1 }
|
||||||
expressions = indexes.map { @axes.expressions[_1] }
|
expressions = indexes.map { @axes.expressions[_1] }
|
||||||
scope = @population.scope.except(:select, :order, :where, :from).from('authorised v')
|
scope = @population.scope.except(:select, :order, :where, :from).from('population_members v')
|
||||||
additive = @axes.rows.any? { _1['field'] == 'tags' }
|
additive = @axes.rows.any? { _1['field'] == 'tags' }
|
||||||
retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes
|
retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes
|
||||||
joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') })
|
joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') })
|
||||||
@@ -47,9 +51,12 @@ class CountsPivotTotals
|
|||||||
if additive
|
if additive
|
||||||
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
|
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
|
||||||
leaf = retained.map { @axes.expressions[_1] }
|
leaf = retained.map { @axes.expressions[_1] }
|
||||||
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
|
selected = leaf.each_with_index.map do |expression, index|
|
||||||
|
"#{ expression } AS k#{ index }"
|
||||||
|
end
|
||||||
|
selected << "#{ @axes.count_expression } AS count"
|
||||||
inner = scope.group(*leaf.map { Arel.sql(_1) })
|
inner = scope.group(*leaf.map { Arel.sql(_1) })
|
||||||
.select(Arel.sql((selected + ["#{ @axes.count_expression } AS count"]).join(', '))).to_sql
|
.select(Arel.sql(selected.join(', '))).to_sql
|
||||||
coordinates = indexes.map { "k#{ retained.index(_1) }" }
|
coordinates = indexes.map { "k#{ retained.index(_1) }" }
|
||||||
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
||||||
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||||
@@ -67,7 +74,7 @@ class CountsPivotTotals
|
|||||||
|
|
||||||
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
|
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
|
||||||
@population.definition.model.connection.select_all(
|
@population.definition.model.connection.select_all(
|
||||||
"WITH authorised AS (#{ basis }) " +
|
"WITH population_members AS (#{ basis }) " +
|
||||||
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
|
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
|
||||||
row_depth = record['row_depth'].to_i
|
row_depth = record['row_depth'].to_i
|
||||||
column_depth = record['column_depth'].to_i
|
column_depth = record['column_depth'].to_i
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ class CountsPopulation
|
|||||||
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Unrestricted predicates are used only for numerical totals, never record output.
|
# Aggregate sources are unrestricted; CountsAxes masks their output coordinates.
|
||||||
|
# Never serialise raw records from an unrestricted population.
|
||||||
def access_sql(model, row)
|
def access_sql(model, row)
|
||||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
end
|
end
|
||||||
@@ -67,6 +68,16 @@ class CountsPopulation
|
|||||||
|
|
||||||
key = entry.fetch('field')
|
key = entry.fetch('field')
|
||||||
raise ArgumentError unless definition.axes(history).include?(key)
|
raise ArgumentError unless definition.axes(history).include?(key)
|
||||||
|
value = entry['value']
|
||||||
|
unless value.nil?
|
||||||
|
valid = case definition.fields[key]
|
||||||
|
when :integer
|
||||||
|
value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?[0-9]+\z/))
|
||||||
|
when :boolean then [true, false].include?(value)
|
||||||
|
else value.is_a?(String)
|
||||||
|
end
|
||||||
|
raise ArgumentError unless valid
|
||||||
|
end
|
||||||
if entry.key?('grain')
|
if entry.key?('grain')
|
||||||
unless definition.fields[key] == :datetime && CountsAxes::GRAINS.include?(entry['grain'])
|
unless definition.fields[key] == :datetime && CountsAxes::GRAINS.include?(entry['grain'])
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
|
|
||||||
def summary_value(value, index, page)
|
def summary_value(value, index, page)
|
||||||
|
return '********' if value == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
field = @query.axes.all.fetch(index).fetch('field')
|
field = @query.axes.all.fetch(index).fetch('field')
|
||||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||||
if ['language_code', 'source_language_code'].include?(field)
|
if ['language_code', 'source_language_code'].include?(field)
|
||||||
|
|||||||
@@ -49,12 +49,18 @@ class CountsTableQuery
|
|||||||
result = metadata.merge(mode == 'summary' ? summary : details)
|
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||||
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||||
totals = output_totals(visible)
|
totals = output_totals(visible)
|
||||||
result.merge(visible_output_count: visible,
|
response = result.merge(visible_output_count: visible,
|
||||||
**totals,
|
**totals,
|
||||||
output_count_scope: 'before_authorisation',
|
output_count_scope: 'before_authorisation',
|
||||||
output_count_unit: 'regular_result_rows',
|
output_count_unit: 'regular_result_rows',
|
||||||
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||||
result[:group_count] > limit)
|
result[:group_count] > limit)
|
||||||
|
if mode == 'summary'
|
||||||
|
response.except!(:excluded_count, :excluded_count_scope)
|
||||||
|
response[:column_values_editable] = HistoryAccess.admin? ||
|
||||||
|
!result.fetch(:column_values, []).flatten.include?(CountsAxes::MASKED_VALUE)
|
||||||
|
end
|
||||||
|
response
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -228,17 +234,17 @@ class CountsTableQuery
|
|||||||
|
|
||||||
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
|
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
|
||||||
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
|
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
|
||||||
private_parameters = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id',
|
keys = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id']
|
||||||
'name_scope'].any? do |key|
|
private_parameters = keys.any? do |key|
|
||||||
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
|
||||||
end
|
end
|
||||||
raise CountsAxes::PrivateCondition if private_fields || private_parameters
|
raise CountsAxes::PrivateCondition if private_fields || private_parameters
|
||||||
|
|
||||||
['column_order', 'measures'].each do |key|
|
['column_order', 'measures'].each do |key|
|
||||||
next if QueryState.records(@params, key).empty?
|
# Coordinates are compared only to already masked expressions, never raw
|
||||||
columns = QueryState.axes(@params.fetch('cols', ''))
|
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||||
raise CountsAxes::PrivateCondition if columns.any? do |axis|
|
raise CountsAxes::PrivateCondition if QueryState.records(@params, key).any? do |record|
|
||||||
!CountsAxes::PUBLIC_FIELDS.include?(axis['field'])
|
record['value'] == CountsAxes::MASKED_VALUE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -350,6 +356,11 @@ class CountsTableQuery
|
|||||||
def measure_relation values
|
def measure_relation values
|
||||||
row_expressions = axes.expressions.first(axes.rows.length)
|
row_expressions = axes.expressions.first(axes.rows.length)
|
||||||
source = summary_population.scope.except(:select, :order)
|
source = summary_population.scope.except(:select, :order)
|
||||||
|
# Omitted multi-value column axes still determine full-cell membership.
|
||||||
|
membership = summary_population.scope.except(:select, :order)
|
||||||
|
all_joins = axes.joins
|
||||||
|
membership = membership.joins(all_joins) unless all_joins.empty?
|
||||||
|
source = source.where("v.id IN (#{ membership.select('v.id').distinct.to_sql })")
|
||||||
fields = axes.rows + axes.columns.first(values.length)
|
fields = axes.rows + axes.columns.first(values.length)
|
||||||
joins = axes.joins(fields.map { _1.fetch('field') })
|
joins = axes.joins(fields.map { _1.fetch('field') })
|
||||||
source = source.joins(joins) unless joins.empty?
|
source = source.joins(joins) unless joins.empty?
|
||||||
@@ -367,7 +378,15 @@ class CountsTableQuery
|
|||||||
def axis_order_sql key, direction = 'asc', qualifier: nil
|
def axis_order_sql key, direction = 'asc', qualifier: nil
|
||||||
column = qualifier ? "#{ qualifier }.#{ key }" : key
|
column = qualifier ? "#{ qualifier }.#{ key }" : key
|
||||||
expression = column
|
expression = column
|
||||||
if axes.all.fetch(key.delete_prefix('a').to_i).fetch('field') == 'category'
|
field = axes.all.fetch(key.delete_prefix('a').to_i).fetch('field')
|
||||||
|
if !HistoryAccess.admin? && [:integer, :boolean].include?(@definition.fields[field])
|
||||||
|
masked = @connection.quote(CountsAxes::MASKED_VALUE)
|
||||||
|
expression = "CASE WHEN #{ column } <=> #{ masked } THEN NULL " +
|
||||||
|
"ELSE CAST(#{ column } AS SIGNED) END"
|
||||||
|
return "#{ column } IS NULL ASC, (#{ column } <=> #{ masked }) ASC, " +
|
||||||
|
"#{ expression } #{ direction.upcase }"
|
||||||
|
end
|
||||||
|
if field == 'category'
|
||||||
# Tag's enum preserves the same category order as TagsController.
|
# Tag's enum preserves the same category order as TagsController.
|
||||||
cases = Tag.categories.keys.each_with_index.map { |category, index|
|
cases = Tag.categories.keys.each_with_index.map { |category, index|
|
||||||
"WHEN #{ @connection.quote(category) } THEN #{ index }"
|
"WHEN #{ @connection.quote(category) } THEN #{ index }"
|
||||||
@@ -418,7 +437,10 @@ class CountsTableQuery
|
|||||||
labels = case field
|
labels = case field
|
||||||
when 'tags' then tag_labels(values)
|
when 'tags' then tag_labels(values)
|
||||||
when 'user'
|
when 'user'
|
||||||
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
|
actors = HistoryAccess.visible(population.scope, 'v').except(:select, :order)
|
||||||
|
.select('v.created_by_user_id')
|
||||||
|
User.where(id: values).where(id: actors)
|
||||||
|
.pluck(:id, :name).to_h.transform_keys(&:to_s)
|
||||||
else {}
|
else {}
|
||||||
end
|
end
|
||||||
[field, labels]
|
[field, labels]
|
||||||
|
|||||||
@@ -67,3 +67,64 @@ RSpec.describe HistoryAccess do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe 'Counts artifact policy and external references' do
|
||||||
|
it 'rejects files created before the masked-summary policy' do
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
metadata = HistoryAccess.authority.except('artifact_policy')
|
||||||
|
expect(HistoryAccess.authorised_artifact?(metadata)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not expose a private linked tag name through an external history snapshot' do
|
||||||
|
tag = create(:tag, primary_name: 'private-linked-name')
|
||||||
|
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
|
||||||
|
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
|
||||||
|
names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
|
||||||
|
auto_generated: false, aliases: [] } })
|
||||||
|
external = create(:external_tag, name: 'public-external')
|
||||||
|
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
|
||||||
|
name: 'nico:public-external', linked_tags: 'private-linked-name',
|
||||||
|
created_at: Time.utc(2024))
|
||||||
|
post = create(:post)
|
||||||
|
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
||||||
|
title: 'external reference', url: post.url, tags: '',
|
||||||
|
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
|
||||||
|
created_at: Time.utc(2024, 2))
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to be_empty
|
||||||
|
expect(HistoryAccess.visible(PostVersion.where(id: snapshot.id))).to be_empty
|
||||||
|
end
|
||||||
|
HistoryAccess.set(user: create(:user, :admin)) do
|
||||||
|
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
|
||||||
|
end
|
||||||
|
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
|
||||||
|
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
|
||||||
|
created_at: Time.utc(2024, 7))
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
|
||||||
|
expect(HistoryAccess.visible(PostVersion.where(id: snapshot.id))).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'propagates the recorded tag version policy to its post snapshot' do
|
||||||
|
parent = create(:tag, primary_name: 'private parent')
|
||||||
|
child = create(:tag, primary_name: 'referencing child')
|
||||||
|
[[parent, 'discard', '', 'private parent'],
|
||||||
|
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
|
||||||
|
TagVersion.create!(tag:, version_no: 1, event_type: event, name:,
|
||||||
|
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
|
||||||
|
names: { ja: { name:, script_code: 'Jpan',
|
||||||
|
auto_generated: false, aliases: [] } })
|
||||||
|
end
|
||||||
|
post = create(:post)
|
||||||
|
row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot',
|
||||||
|
url: post.url, tags: '', parent_post_ids: '',
|
||||||
|
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
|
||||||
|
created_at: Time.utc(2024))
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
expect(HistoryAccess.visible(PostVersion.where(id: row.id))).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'tmpdir'
|
||||||
|
|
||||||
|
RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
||||||
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
|
around do |example|
|
||||||
|
Dir.mktmpdir('counts-download-request') do |path|
|
||||||
|
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
|
||||||
|
example.run
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ready_job(format)
|
||||||
|
CountsExportJobs.pulse
|
||||||
|
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
|
||||||
|
mode: 'details', columns: 'resource_id', locale: 'ja', format: }
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
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'])
|
||||||
|
file = HistoryAccess.set(user: nil) { CountsExportDownload.prepare(exporter, owner) }
|
||||||
|
state = CountsExportJobs.read(signature.fetch('id'))
|
||||||
|
state.merge!('status' => 'ready', 'token' => file)
|
||||||
|
CountsExportJobs.write(state)
|
||||||
|
post "/counts/export/jobs/#{ job }/download"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
[job, file, response.parsed_body.fetch('download_attempt')]
|
||||||
|
end
|
||||||
|
|
||||||
|
['csv', 'json'].each do |format|
|
||||||
|
it "acknowledges only an authorised #{ format } GET body, not HEAD or acceptance" do
|
||||||
|
job, file, attempt = ready_job(format)
|
||||||
|
get "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response.parsed_body['download_status']).to eq('pending')
|
||||||
|
head "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:method_not_allowed)
|
||||||
|
get "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response.parsed_body['download_status']).to eq('pending')
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.headers['Content-Disposition']).to include("counts-posts.#{ format }")
|
||||||
|
expect(response.body).to eq('payload')
|
||||||
|
get "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response.parsed_body['download_status']).to eq('started')
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects wrong attempts, cancellation and a newly discarded resource' do
|
||||||
|
job, file, attempt = ready_job('json')
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: 'wrong' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
delete "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
job, file, attempt = ready_job('json')
|
||||||
|
record = create(:post)
|
||||||
|
PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private',
|
||||||
|
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
expect(response.body).not_to include('payload', 'private')
|
||||||
|
end
|
||||||
|
it 'does not let a second browser session inspect the job or download its file' do
|
||||||
|
job, file, attempt = ready_job('json')
|
||||||
|
other = ActionDispatch::Integration::Session.new(Rails.application)
|
||||||
|
other.get("/counts/export/jobs/#{ job }")
|
||||||
|
expect(other.response).to have_http_status(:not_found)
|
||||||
|
other.get("/counts/export/files/#{ file }", params: { job:, attempt: })
|
||||||
|
expect(other.response).to have_http_status(:not_found)
|
||||||
|
expect(other.response.body).not_to include('payload')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not acknowledge an expired attempt' do
|
||||||
|
job, file, attempt = ready_job('json')
|
||||||
|
travel_to(Time.current + 46.seconds) do
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
get "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response.parsed_body['download_status']).to eq('timeout')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'records a missing file without accepting the GET as started' do
|
||||||
|
job, file, attempt = ready_job('json')
|
||||||
|
owner = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job').fetch('owner')
|
||||||
|
metadata = HistoryAccess.set(user: nil) { CountsExportDownload.find(file, owner) }
|
||||||
|
File.unlink(metadata.fetch('path'))
|
||||||
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
get "/counts/export/jobs/#{ job }"
|
||||||
|
expect(response.parsed_body['download_status']).to eq('unavailable')
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -266,4 +266,12 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
expect(json).to eq('error' => 'private_condition')
|
expect(json).to eq('error' => 'private_condition')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||||
|
get '/counts/query', params: base.merge(mode: 'summary', sort: '',
|
||||||
|
rows: 'resource_id version_no event_type user created_at:month',
|
||||||
|
cols: 'title url thumbnail_base video_ms')
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
expect(response.parsed_body).to eq('error' => 'too_many_axes', 'max_axes' => 8)
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -193,11 +193,49 @@ RSpec.describe 'Counts download acknowledgement' do
|
|||||||
arguments = { file_token: 'file-token', attempt: receipt.fetch('download_attempt') }
|
arguments = { file_token: 'file-token', attempt: receipt.fetch('download_attempt') }
|
||||||
expect(CountsExportJobs.download_attempt(token, 'other', **arguments)).to be_nil
|
expect(CountsExportJobs.download_attempt(token, 'other', **arguments)).to be_nil
|
||||||
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments.merge(file_token: 'wrong'))).to be_nil
|
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments.merge(file_token: 'wrong'))).to be_nil
|
||||||
|
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'streaming')
|
||||||
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'started')
|
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'started')
|
||||||
expect(CountsExportJobs.status(token, 'owner')['download_status']).to eq('started')
|
expect(CountsExportJobs.status(token, 'owner')['download_status']).to eq('started')
|
||||||
CountsExportJobs.download_attempt(token, 'owner')
|
expect(CountsExportJobs.download_attempt(token, 'owner')['download_attempt'])
|
||||||
|
.to eq(receipt['download_attempt'])
|
||||||
|
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'failed')
|
||||||
|
fresh = CountsExportJobs.download_attempt(token, 'owner')
|
||||||
|
expect(fresh['download_attempt']).not_to eq(receipt['download_attempt'])
|
||||||
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments)).to be_nil
|
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments)).to be_nil
|
||||||
CountsExportJobs.status(token, 'owner', cancel: true)
|
CountsExportJobs.status(token, 'owner', cancel: true)
|
||||||
expect(CountsExportJobs.download_attempt(token, 'owner')).to be_nil
|
expect(CountsExportJobs.download_attempt(token, 'owner')).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe CountsExportDownload::ResponseBody do
|
||||||
|
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 }
|
||||||
|
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(states.count('started')).to eq(1)
|
||||||
|
expect(body).not_to respond_to(:to_path)
|
||||||
|
expect { body.close }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'records a failed stream without converting an exception into a second HTTP response' do
|
||||||
|
Dir.mktmpdir('counts-body-spec') do |directory|
|
||||||
|
path = File.join(directory, 'file')
|
||||||
|
File.binwrite(path, 'data')
|
||||||
|
states = []
|
||||||
|
body = described_class.new(path) { |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')
|
||||||
|
expect { body.close }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'csv'
|
||||||
|
|
||||||
|
RSpec.describe 'Counts masked pivot contracts' do
|
||||||
|
let(:admin) { create(:user, :admin) }
|
||||||
|
let(:member) { create(:user, :member) }
|
||||||
|
let(:guest) { create(:user, :guest) }
|
||||||
|
let(:params) do
|
||||||
|
{ 'resource' => 'posts', 'definition' => 'history', 'mode' => 'summary',
|
||||||
|
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
|
||||||
|
'timezone' => 'UTC', 'rows' => 'tags', 'cols' => 'event_type', 'sort' => '' }
|
||||||
|
end
|
||||||
|
let!(:tags) do
|
||||||
|
['A', 'B', 'C'].map do |name|
|
||||||
|
tag = create(:tag, primary_name: name)
|
||||||
|
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:,
|
||||||
|
category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
|
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||||
|
created_at: Time.utc(2023))
|
||||||
|
tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
|
||||||
|
post = create(:post)
|
||||||
|
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
|
||||||
|
PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
||||||
|
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
|
||||||
|
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
|
||||||
|
next if index == 2
|
||||||
|
|
||||||
|
PostVersion.create!(post:, version_no: 2, event_type: 'discard',
|
||||||
|
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
|
||||||
|
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def result(user, extra = {})
|
||||||
|
HistoryAccess.set(user:) { CountsTableQuery.new(params.merge(extra)).call }
|
||||||
|
end
|
||||||
|
|
||||||
|
def grand(result)
|
||||||
|
result.fetch(:pivot_totals).find { _1[:row].empty? && _1[:column].empty? }.fetch(:count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves five tag occurrences after merging private tags and across pages' do
|
||||||
|
[nil, guest, member, admin].each do |user|
|
||||||
|
page = result(user)
|
||||||
|
expect(grand(page)).to eq(5)
|
||||||
|
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
||||||
|
expect(page).not_to have_key(:excluded_count)
|
||||||
|
paged = result(user, 'limit' => '1')
|
||||||
|
expect(grand(paged)).to eq(5)
|
||||||
|
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||||
|
.to eq(page[:group_count])
|
||||||
|
end
|
||||||
|
public_page = result(nil)
|
||||||
|
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||||
|
expect(hidden.sum { _1[:count] }).to eq(3)
|
||||||
|
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
||||||
|
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'retains distinct membership when tags are not a row axis' do
|
||||||
|
[nil, admin].each do |user|
|
||||||
|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
||||||
|
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||||
|
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||||
|
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses changed-record membership for totals and hides changes to private predecessors' do
|
||||||
|
post = PostVersion.order(:id).last.post
|
||||||
|
PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
|
||||||
|
url: post.url, tags: '',
|
||||||
|
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
|
||||||
|
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||||
|
restored = PostVersion.order(:id).first.post
|
||||||
|
PostVersion.create!(post: restored, version_no: 3, event_type: 'restore',
|
||||||
|
title: 'restored-public-title', url: restored.url, tags: '',
|
||||||
|
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
|
||||||
|
created_at: Time.utc(2024, 4))
|
||||||
|
[nil, admin].each do |user|
|
||||||
|
page = result(user, 'rows' => 'tags changed_field', 'cols' => 'event_type')
|
||||||
|
expect(grand(page)).to eq(page[:cells].sum { _1[:count] })
|
||||||
|
expect(page[:cells]).not_to be_empty
|
||||||
|
if user.nil?
|
||||||
|
expect(page[:cells].any? { _1[:values][1] == CountsAxes::MASKED_VALUE }).to be(true)
|
||||||
|
end
|
||||||
|
expect(page.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||||
|
[nil, admin].each do |user|
|
||||||
|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||||
|
expect(grand(page)).to eq(2)
|
||||||
|
if user.nil?
|
||||||
|
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||||
|
end
|
||||||
|
# Changed discard snapshots have no tags: no full cell contributes here.
|
||||||
|
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||||
|
expect(empty[:cells]).to be_empty
|
||||||
|
expect(grand(empty)).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps NULL as a public data value rather than treating it as a rollup' do
|
||||||
|
post = create(:post)
|
||||||
|
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot',
|
||||||
|
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
|
||||||
|
page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
|
||||||
|
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
|
||||||
|
expect(grand(page)).to eq(6)
|
||||||
|
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sorts public numeric values numerically alongside a masked group' do
|
||||||
|
post = PostVersion.order(:id).last.post
|
||||||
|
[2, 10].each do |number|
|
||||||
|
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
||||||
|
title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
|
||||||
|
parent_post_ids: '', created_at: Time.utc(2024, 5))
|
||||||
|
end
|
||||||
|
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||||
|
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||||
|
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
||||||
|
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
||||||
|
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
||||||
|
{ 'tags' => 'A' }, { 'tag_refs.0.name' => 'A', 'tag_refs.0.tag_id' => tags.first.id.to_s },
|
||||||
|
{ 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
||||||
|
'conditions.0.value' => 'hidden-title-0' },
|
||||||
|
{ 'drill.0.field' => 'tags', 'drill.0.value' => "internal:#{ tags.first.id }" },
|
||||||
|
{ 'cols' => 'tags', 'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
probes.each do |probe|
|
||||||
|
expect { CountsTableQuery.new(params.merge(probe)) }
|
||||||
|
.to raise_error(CountsAxes::PrivateCondition)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||||
|
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||||
|
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||||
|
'column_order.0.value' => "internal:#{ tags.first.id }")
|
||||||
|
expect(probe[:column_values]).to eq(first[:column_values])
|
||||||
|
expect(probe[:cells]).to eq(first[:cells])
|
||||||
|
expect(probe[:column_values_editable]).to be(false)
|
||||||
|
expect(probe.to_json).not_to include("internal:#{ tags.first.id }")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'exports masked coordinates and safe CSV labels from the same pivot' do
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
payload = params.merge('format' => 'json')
|
||||||
|
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||||
|
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
||||||
|
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
|
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||||
|
expect(csv).to include('********')
|
||||||
|
expect(csv).not_to include(CountsAxes::MASKED_VALUE, 'hidden-title', 'private-discard')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+45
-8
@@ -35,7 +35,9 @@ Web とワーカーは同一リリース、Rails secret、アプリケーショ
|
|||||||
- POST `/counts/export/jobs`:要求検証・実行時点の固定・署名付きジョブトークンの発行。
|
- POST `/counts/export/jobs`:要求検証・実行時点の固定・署名付きジョブトークンの発行。
|
||||||
- GET `/counts/export/jobs/:token`:状態、実際の処理済みレコード数、判明している総数。
|
- GET `/counts/export/jobs/:token`:状態、実際の処理済みレコード数、判明している総数。
|
||||||
- DELETE `/counts/export/jobs/:token`:取消を永続記録。
|
- DELETE `/counts/export/jobs/:token`:取消を永続記録。
|
||||||
- GET `/counts/export/files/:token`:生成完了後の署名付きファイルトークンによる取得。
|
- POST `/counts/export/jobs/:token/download`:セッション所有者の開始確認要求を登録。
|
||||||
|
- GET `/counts/export/files/:token?job=...&attempt=...`:実 GET の認可・送出開始の確認。
|
||||||
|
- GET `/counts/export/files/:token`:既存クライアント向けの認可付きダウンロード。
|
||||||
|
|
||||||
ジョブ・ファイルのトークンは作成元のセッションに紐付く。
|
ジョブ・ファイルのトークンは作成元のセッションに紐付く。
|
||||||
既存の BAN・CSRF 制御を使用する。フロントは約800ms間隔で状態を取得する。
|
既存の BAN・CSRF 制御を使用する。フロントは約800ms間隔で状態を取得する。
|
||||||
@@ -80,16 +82,22 @@ processed は CSV では実際に生成したデータ行数、JSON のクロス
|
|||||||
|
|
||||||
通常の Counts 応答では、使用していない全履歴の population_count は計算しない。
|
通常の Counts 応答では、使用していない全履歴の population_count は計算しない。
|
||||||
キーは null、population_count_computed は false とし、0に置き換えない。
|
キーは null、population_count_computed は false とし、0に置き換えない。
|
||||||
現在条件の output_count は権限適用前、visible_output_count は権限適用後の
|
summary は非公開履歴を含む同じ母集団を集計する。非公開の軸はサーバーの CASE 式で
|
||||||
通常の集計行数(明細では明細行数)。小計・合計の物理行は含めない。
|
単一の予約値へまとめてから GROUP BY し、output_count / visible_output_count はその
|
||||||
|
安全化した通常行数を表す。excluded_count は返さない。details は従来どおり
|
||||||
|
output_count が権限適用前、visible_output_count が適用後の明細行数である。
|
||||||
|
小計・合計の物理行は行数に含めない。
|
||||||
CSV の物理行と JSON の pivot_cell には集計レコードを含むため、両者を混同しない。
|
CSV の物理行と JSON の pivot_cell には集計レコードを含むため、両者を混同しない。
|
||||||
|
|
||||||
除票境界・参照禁止集合は GROUP BY で識別キー単位にまとめる。
|
除票境界・参照禁止集合は GROUP BY で識別キー単位にまとめる。
|
||||||
投稿の参照判定は、全除票履歴を JSON_CONTAINS / FIND_IN_SET で反復照合せず、
|
投稿の参照判定は、全除票履歴を JSON_CONTAINS / FIND_IN_SET で反復照合せず、
|
||||||
保存済みスナップショットから参照IDを JSON_TABLE で展開して照合する。
|
保存済みスナップショットから参照IDを JSON_TABLE で展開して照合する。
|
||||||
権限条件付きの母集団件数は同一 export インスタンス内で再利用する。
|
権限条件付きの母集団件数は同一 export インスタンス内で再利用する。
|
||||||
多段小計は、認可済み母集団の CTE を MySQL の無制限 LIMIT で実体化させ、
|
多段小計は、全軸の JOIN へ実際に寄与するレコードを CTE に保持し、各枝で同じ
|
||||||
各枝が同じ認可判定をやり直すことを避ける。計数は COUNT(DISTINCT v.id) のまま。
|
母集合を使う。changed_field には直前版がある実変更だけが寄与する。タグが行軸に
|
||||||
|
ない場合は DISTINCT 投稿版を数える。タグが行軸にある場合は投稿版×タグを数え、
|
||||||
|
全行軸の葉単位から小計・合計を求める。変更項目も行軸ならその寄与も保持する。
|
||||||
|
private のタグ・変更項目をまとめても、内部の寄与は捨てず、ラベルだけを保護する。
|
||||||
|
|
||||||
スキーマ上、以下の追加インデックスが候補となる。migration は作成・実行していない。
|
スキーマ上、以下の追加インデックスが候補となる。migration は作成・実行していない。
|
||||||
|
|
||||||
@@ -122,6 +130,35 @@ systemd の EnvironmentFile と Web の環境を照合し、directory の owner
|
|||||||
アクセス制限、容量、期限切れ削除、worker heartbeat と restart を公開前に確認する。
|
アクセス制限、容量、期限切れ削除、worker heartbeat と restart を公開前に確認する。
|
||||||
この作業では systemctl・worker 起動・本番環境への適用を行っていない。
|
この作業では systemctl・worker 起動・本番環境への適用を行っていない。
|
||||||
|
|
||||||
リリース保留:非公開グループと複数値・変更項目軸の交差集計、生成ファイルへの秘匿値の
|
これらのコード対策と回帰テストを追加しているが、テスト・型チェック・build は未実行。
|
||||||
非混入、実 GET の middleware/proxy 経由の開始通知は、回帰テストと実環境の確認が必要。
|
middleware/proxy 経由の転送は別途実環境で確認する。
|
||||||
テスト・型チェック・build は未実行。
|
|
||||||
|
|
||||||
|
## 開始確認の競合と権限
|
||||||
|
|
||||||
|
開始受付は pending、実 GET の単独取得は streaming、最初の Rack yield 後は started。
|
||||||
|
同じ job の重複受付は既存 attempt を返す。streaming への遷移はプロセス間ロックで
|
||||||
|
単独取得し、同一 attempt の二重 GET を拒否する。開始前の attempt は45秒で失効する。
|
||||||
|
Frontend は30秒の独立タイマーで確認を打ち切る。一時的なポーリング通信断は同じ
|
||||||
|
attempt で再取得するが、別の GET は始めない。取消失敗後の再試行は利用者の再操作で行う。
|
||||||
|
|
||||||
|
ファイル・ジョブは owner、署名、期限、現在の権限 revision 及び artifact_policy を
|
||||||
|
検証する。最初の送出前にも現ユーザー・BAN・ファイル権限を再確認する。旧ポリシーの
|
||||||
|
生成物は再利用しない。送出中は各チャンクで取消状態を確認し、例外時はログと failed
|
||||||
|
状態を記録して body を閉じる。コミット後に head で別レスポンスを書かない。
|
||||||
|
|
||||||
|
ResponseBody に to_path を提供せず、sendfile middleware による ACK の迂回を避ける。
|
||||||
|
Last-Modified と X-Accel-Buffering: no を設定するが、プロキシの動作まで保証しない。
|
||||||
|
サーバーの最初の Rack yield は、クライアントの受信開始の厳密な証明ではない。
|
||||||
|
|
||||||
|
非管理者の summary では、公開集計属性は操作日時・異動区分。その他の属性に対する
|
||||||
|
抽出条件・補助タグ検索は拒否する。measures / column_order はマスク後の式だけと
|
||||||
|
比較するため、推測した private ID は一致しない。予約値そのものの条件指定は拒否する。
|
||||||
|
private ID の正誤に応じて許可可否を変えないため、条件の一致探索を避けられる。
|
||||||
|
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
|
||||||
|
公開列の値を使うソートは引き続き利用できる。
|
||||||
|
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
|
||||||
|
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
|
||||||
|
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
|
||||||
|
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
|
||||||
|
名前の公開可否を楽観的に推定しない。
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Counts リリース前確認
|
||||||
|
|
||||||
|
この作業では Git、テスト、lint、typecheck、build、migration、サーバー・worker・
|
||||||
|
systemd の起動、本番適用を行っていない。コードの静的確認とテストコードの編集のみ。
|
||||||
|
以下の実行と結果確認は利用者が行う。未実行の状態は GO としない。
|
||||||
|
|
||||||
|
## 対応箇所
|
||||||
|
|
||||||
|
- 全件集計と安全な軸:counts_axes.rb / counts_table_query.rb / counts_pivot_totals.rb。
|
||||||
|
非公開値は GROUP BY 前に統合。通常セル・ソート用集計・小計は同じ寄与母集合を使う。
|
||||||
|
- 出力:counts_table_export.rb / countsDisplay.ts / CountsSummaryResults.tsx。
|
||||||
|
CSV とコピーは伏字。JSON の非公開座標は予約値のみ。非公開セルはリンクを作らない。
|
||||||
|
- 配布:counts_exports_controller.rb / counts_export_jobs.rb / counts_export_download.rb。
|
||||||
|
owner/job/attempt、単独 GET、期限・現在権限・旧 artifact policy の拒否を確認する。
|
||||||
|
- 表示:summary 行番号を廃止し「行数」を表示。details の行番号・件数は維持。
|
||||||
|
非公開列座標のソート・並び替え可否はサーバーの能力フラグへ従う。
|
||||||
|
- テスト基盤:requestAnimationFrame の同期・時刻0固定を修正。JSDOM の Canvas は
|
||||||
|
計測非対応を返すテスト専用モックを置く。実ブラウザーの計測動作は変更していない。
|
||||||
|
|
||||||
|
## 利用者によるテスト
|
||||||
|
|
||||||
|
backend ディレクトリで提案する対象(未実行):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bundle exec rspec spec/requests/counts_table_spec.rb spec/requests/counts_download_spec.rb spec/services/counts_pivot_security_spec.rb spec/services/counts_export_spec.rb spec/models/history_access_spec.rb spec/requests/post_locale_filter_identity_spec.rb spec/requests/tag_description_wiki_spec.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend ディレクトリで提案する対象(未実行):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:run -- src/lib/counts.test.ts src/lib/api.test.ts src/pages/CountsResourcePage.test.tsx src/pages/help/HelpChangesPage.test.tsx src/components/counts/CountsControls.test.tsx src/components/counts/CountsSummaryResults.test.tsx src/components/counts/CountsExportProgress.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
SSR は frontend/server.ssr.test.mjs が dist/server/entry-server.js を読み込む。
|
||||||
|
ソースの PageHead はサイト名付きの共通タイトルを使用している。古い成果物のまま
|
||||||
|
SSR テストを実行しても現行ソースの検証にならない。利用者が最新成果物を用意してから
|
||||||
|
`npm run test:ssr` を実行する。Codex はビルド・SSR テストとも実行していない。
|
||||||
|
主要テストの後に既存の全テスト群でも回帰の有無を確認する。
|
||||||
|
|
||||||
|
## ブラウザーと本番切替前
|
||||||
|
|
||||||
|
- anonymous / guest / member / admin で同じ期間・公開条件を指定し、通常セル・総計の
|
||||||
|
数値を比較する。安全なグループ数は権限で異なり得る。details は秘匿版を出さない。
|
||||||
|
- 投稿 A,B / A / B,C のタグ行軸は5。タグを行軸から外した合計は distinct 投稿数。
|
||||||
|
changed_field、NULL、複数段小計、ページ境界、CSV / JSON でも値を比較する。
|
||||||
|
- 伏字セル、API 辞書、DOM、コピー、CSV / JSON に非公開の値・ID がないこと。
|
||||||
|
非公開セルのドリルダウンはなく、private 条件の summary は安全な403となること。
|
||||||
|
- 8軸・100列の専用エラー、SQL timeout、worker 不在、認証・権限、取消失敗を確認する。
|
||||||
|
- CSV / JSON とも ready / 100% だけでは閉じず、実 GET の matching attempt が
|
||||||
|
started となってから閉じること。通信遮断・取消・再描画・タイムアウトも確認する。
|
||||||
|
サーバー送出開始はブラウザーの受信完了・保存完了を保証しない。
|
||||||
|
- PC・スマホで独立/共通スクロール、D&D、列幅、セル選択・文字列選択・キャレット、
|
||||||
|
内表 JSON の折返し、ページタイトル、Counts の noindex・先読み禁止を確認する。
|
||||||
|
locale 切替後も対象 ID と集計値は同一で、全言語の登録値を保持すること。
|
||||||
|
- Web と worker の同一リリース・secret・DB・COUNTS_EXPORT_DIRECTORY、所有者、
|
||||||
|
flock / atomic rename、容量、期限切れ掃除、heartbeat、ログ、再起動を照合する。
|
||||||
|
- systemd unit の User / WorkingDirectory / EnvironmentFile / PATH を実環境と照合する。
|
||||||
|
設定適用・サービス起動・本番切替はこの作業では行っていない。
|
||||||
|
- ロール変更・logout・新しい discard 後に、生成済みファイルが再取得できないこと。
|
||||||
|
- ロールバック時も公開ダウンロードを止め、旧生成物を再配布しない。古いコードへ戻すと
|
||||||
|
artifact_policy やマスクの保護を失う可能性があるため、無条件に巻き戻さない。
|
||||||
|
|
||||||
|
P0 の回帰テストと利用者の動作確認が済むまで判定は NO-GO。
|
||||||
|
|
||||||
|
## 静的な対応状況
|
||||||
|
|
||||||
|
| 対象 | 状態 | 根拠 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 非公開軸・参照・全件母集団 | 対応済み(静的確認のみ) | HistoryAccess / CountsAxes / CountsTableQuery と回帰コード |
|
||||||
|
| タグ・変更項目・小計 | 対応済み(静的確認のみ) | CountsPivotTotals と全セル・ソートの母集合の統一 |
|
||||||
|
| API / CSV / JSON / コピー / URL | 対応済み(静的確認のみ) | マスク後の値のみを表示・比較し、予約座標の条件化を拒否 |
|
||||||
|
| GET ACK・競合・取消・期限 | 対応済み(静的確認のみ) | サーバー状態遷移、単独 GET、独立タイマー、回帰コード |
|
||||||
|
| P1・既知テストのソース上の問題 | 対応済み(静的確認のみ) | 行番号、行数、エラー、RAF、fixture、title 契約 |
|
||||||
|
| テスト成功・実ブラウザー・本番稼働 | 判定不能 | 実行禁止のため未検証 |
|
||||||
|
|
||||||
|
## 今回の編集ファイル
|
||||||
|
|
||||||
|
すべて Counts 本体に必要な変更。共有箇所の例外は HistoryAccess の参照秘匿と、
|
||||||
|
api.ts の Abort/CSRF 待機・native download の機構であり、全域の安全性にも必要。
|
||||||
|
SQL の速度改善・索引追加は別 Issue とし、今回 migration は追加していない。
|
||||||
|
|
||||||
|
### Backend App
|
||||||
|
|
||||||
|
- backend/app/models/history_access.rb:外部タグの記録名参照と、投稿の記録済み参照版にも
|
||||||
|
同じ可視性判定を適用。旧 artifact policy の生成物を拒否。
|
||||||
|
- backend/app/services/counts_population.rb:軸条件の型・予約値の検証。
|
||||||
|
- backend/app/services/counts_axes.rb:マスク前の内部寄与を計数し、直前版が非公開の場合も
|
||||||
|
changed_field の名前を秘匿。
|
||||||
|
- backend/app/services/counts_table_query.rb:通常セル・ソートの寄与母集合、整数順、
|
||||||
|
条件表示名、公開列座標だけの操作、summary メタデータを整合。
|
||||||
|
- backend/app/services/counts_pivot_totals.rb:全セルに寄与する母集合と、タグ行軸の加算。
|
||||||
|
- backend/app/services/counts_table_export.rb:CSV の予約値を伏字化。
|
||||||
|
- backend/app/services/counts_export_jobs.rb:開始確認の排他・状態遷移・失効と安全なエラーコード。
|
||||||
|
- backend/app/services/counts_export_download.rb:64KiB の Rack body、開始確認、取消・close。
|
||||||
|
- backend/app/controllers/counts_exports_controller.rb:GET 直前・送出直前の再認可と専用エラー。
|
||||||
|
|
||||||
|
### Frontend App
|
||||||
|
|
||||||
|
- frontend/src/lib/api.ts:CSRF 待機中も中断可能にし、iframe の寿命・失敗通知を整理。
|
||||||
|
- frontend/src/lib/counts.ts:マスク予約値と列操作の API 型。
|
||||||
|
- frontend/src/lib/countsDisplay.ts:マスク表示と既知エラーの i18n 解決。
|
||||||
|
- frontend/src/components/counts/CountsExportProgress.tsx:一つのジョブを維持し、matching ACK、
|
||||||
|
独立期限、取消・再描画・通信断を処理。保存完了の成功表示はしない。
|
||||||
|
- frontend/src/components/counts/CountsSummaryResults.tsx:行番号コードの撤去、私的セルの
|
||||||
|
ドリルダウン抑止、公開列のソート維持。
|
||||||
|
- frontend/src/components/counts/CountsSortHeader.tsx:禁止座標のソート抑止と伏字の理由表示。
|
||||||
|
- frontend/src/pages/CountsResourcePage.tsx:専用エラーと、初回失敗後にも再実行できる状態。
|
||||||
|
- frontend/src/locales/ja/counts.json:承認されたエラー文言。
|
||||||
|
- frontend/src/locales/ain-Kana/counts.json:同じキーと未翻訳・日本語 fallback の管理。
|
||||||
|
|
||||||
|
### テスト・テスト専用基盤
|
||||||
|
|
||||||
|
- backend/spec/services/counts_pivot_security_spec.rb(新規):4権限、タグ寄与5、distinct、
|
||||||
|
changed_field、private group、NULL、整数順、ページ、座標探索、JSON/CSV。
|
||||||
|
- backend/spec/requests/counts_download_spec.rb(新規):CSV/JSON の実 GET、HEAD、二重取得、
|
||||||
|
別セッション、失効、取消、ファイル不在、新しい discard。
|
||||||
|
- backend/spec/services/counts_export_spec.rb:owner/job/attempt の ACK と Rack body の失敗。
|
||||||
|
- backend/spec/models/history_access_spec.rb:旧 policy、外部タグ参照、記録済みタグ参照版。
|
||||||
|
- backend/spec/requests/counts_table_spec.rb:専用軸数エラー。
|
||||||
|
- frontend/src/components/counts/CountsExportProgress.test.tsx(新規):matching ACK、StrictMode、
|
||||||
|
再描画、開始受付失敗、通信断、独立期限、取消の競合・再試行、unmount、iframe 失敗。
|
||||||
|
- frontend/src/components/counts/CountsSummaryResults.test.tsx(新規):伏字・理由・コピー・旧行番号。
|
||||||
|
- frontend/src/components/counts/CountsControls.test.tsx:summary 行番号廃止と表示件数選択。
|
||||||
|
- frontend/src/pages/CountsResourcePage.test.tsx:初期エラーからの再実行、サイト名付き title。
|
||||||
|
- frontend/src/lib/api.test.ts:共有 CSRF 待機中の中断、iframe の解放・重複失敗通知。
|
||||||
|
- frontend/src/lib/counts.test.ts:理由別エラーと任意のサーバー文字列を表示しないこと。
|
||||||
|
- frontend/src/test/setup.ts:時刻が進む非同期 RAF と Canvas のテスト専用 fallback。
|
||||||
|
- frontend/server.ssr.test.mjs:共通 title の既存 assertion を維持し canonical と秘匿を補足。
|
||||||
|
|
||||||
|
### 文書
|
||||||
|
|
||||||
|
- docs/counts-exports.md:実装と一致する計数・ACK・権限・運用上の限界。
|
||||||
|
- docs/counts-release-checklist.md:この一覧と利用者の検証・公開判定手順。
|
||||||
|
|
||||||
|
## 既知失敗の静的な確認結果
|
||||||
|
|
||||||
|
- counts_table_spec.rb:TagVersion.names の auto_generated、script_code、aliases があることを
|
||||||
|
確認。新しい投稿タグ fixture にも schema 必須の sections を設定。
|
||||||
|
- post_locale_filter_identity_spec.rb:検索文字列で基準集合を取得した後、tag_filters の
|
||||||
|
応答から references を定義して構造化 URL を作る修正を維持。
|
||||||
|
- tag_description_wiki_spec.rb:first / last とも対象 ID に限定した delete_all を維持。
|
||||||
|
- CountsResourcePage.test.tsx / HelpChangesPage.test.tsx:共有 RAF の同期・時刻0固定を修正。
|
||||||
|
Help の辞書応答は実 getHelpChanges が language を付ける契約も照合済み。
|
||||||
|
- SSR title:現行ソースはサイト名を含む。dist を使うテストの assertion は削除していない。
|
||||||
|
|
||||||
|
上記はソース照合の結果であり、過去の失敗件数が0になったという実行結果ではない。
|
||||||
|
既知の未修正 P0 を残して GO とする判断はしていない。現時点の阻害事項は、ユーザーに
|
||||||
|
よる MySQL・ブラウザー・worker/proxy を含む実検証がまだ行われていないことである。
|
||||||
@@ -345,6 +345,9 @@ test ('counts SSR uses query/definitions, noindex and the common site title', as
|
|||||||
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
|
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
|
||||||
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
|
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
|
||||||
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
|
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
|
||||||
|
assert.match (result.head, /rel="canonical"/)
|
||||||
|
assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/)
|
||||||
|
|
||||||
assert.ok (requests.some (request => {
|
assert.ok (requests.some (request => {
|
||||||
const url = new URL (request.url, origin)
|
const url = new URL (request.url, origin)
|
||||||
return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
|
return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
|
||||||
|
|||||||
@@ -117,3 +117,17 @@ it ('derives mixed parent selection and permits selecting all children again aft
|
|||||||
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
|
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
|
||||||
expect (params.get ('columns')).toBe ('tags')
|
expect (params.get ('columns')).toBe ('tags')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it ('offers row numbers only in details and keeps limit in display settings', () => {
|
||||||
|
const view = renderControls (appliedParams ({ mode: 'summary', row_numbers: '1' }))
|
||||||
|
expect (screen.queryByRole ('checkbox', { name: '行番号' })).not.toBeInTheDocument ()
|
||||||
|
const limit = screen.getByRole ('combobox', { name: '表示件数' })
|
||||||
|
expect (Array.from (limit.querySelectorAll ('option')).map (option => option.value))
|
||||||
|
.toEqual (['20', '50', '100'])
|
||||||
|
expect (limit).toHaveValue ('100')
|
||||||
|
expect (limit.querySelector ('span')).toBeNull ()
|
||||||
|
view.unmount ()
|
||||||
|
renderControls (appliedParams ({ row_numbers: '1' }))
|
||||||
|
expect (screen.getByRole ('checkbox', { name: '行番号' })).toBeChecked ()
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { act, cleanup, screen } from '@testing-library/react'
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import CountsExportProgress from '@/components/counts/CountsExportProgress'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiPost: vi.fn (), apiGet: vi.fn (), apiDelete: vi.fn (), startAPIDownload: vi.fn () }))
|
||||||
|
vi.mock ('@/lib/api', async original => ({
|
||||||
|
...await original<typeof import ('@/lib/api')> (), ...api }))
|
||||||
|
|
||||||
|
const ready = { status: 'ready', processed: 5, total: 5, token: 'file' }
|
||||||
|
const receipt = { downloadAttempt: 'attempt', downloadStatus: 'pending', token: 'file' }
|
||||||
|
const controls = () => ({ close: vi.fn (), setActions: vi.fn (), confirm: vi.fn () })
|
||||||
|
const flush = async (milliseconds = 0) => act (async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync (milliseconds)
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.useFakeTimers ()
|
||||||
|
Object.values (api).forEach (mock => mock.mockReset ())
|
||||||
|
api.apiPost.mockImplementation (async (path: string) =>
|
||||||
|
path.endsWith ('/download') ? receipt : { job: 'job' })
|
||||||
|
api.apiGet.mockResolvedValue (ready)
|
||||||
|
api.apiDelete.mockResolvedValue ({ status: 'cancelled', processed: 5, total: 5 })
|
||||||
|
api.startAPIDownload.mockReturnValue (vi.fn ())
|
||||||
|
})
|
||||||
|
afterEach (() => { cleanup (); vi.useRealTimers () })
|
||||||
|
|
||||||
|
const renderProgress = (format = 'csv') => {
|
||||||
|
const actions = controls ()
|
||||||
|
const handle = { cancel: async () => false }
|
||||||
|
const element = (next = actions) => <StrictMode>
|
||||||
|
<CountsExportProgress payload={{ resource: 'posts', format }} locale="ja"
|
||||||
|
handle={handle} controls={next}/>
|
||||||
|
</StrictMode>
|
||||||
|
return { ...renderWithProviders (element ()), actions, handle, element }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('Counts native download acknowledgement', () => {
|
||||||
|
it.each (['csv', 'json']) ('closes %s after the matching GET acknowledgement', async format => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValue ({ ...ready,
|
||||||
|
downloadAttempt: 'attempt', downloadStatus: 'started' })
|
||||||
|
const view = renderProgress (format)
|
||||||
|
await flush ()
|
||||||
|
expect (api.apiPost).toHaveBeenCalledTimes (2)
|
||||||
|
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
const query = api.startAPIDownload.mock.calls[0][3] as URLSearchParams
|
||||||
|
expect (Object.fromEntries (query)).toEqual ({ job: 'job', attempt: 'attempt' })
|
||||||
|
const next = controls ()
|
||||||
|
view.rerender (view.element (next))
|
||||||
|
await flush (800)
|
||||||
|
expect (next.close).toHaveBeenCalledTimes (1)
|
||||||
|
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('times out independently of pending status requests', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (ready).mockImplementation (() => new Promise (() => {}))
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
await flush (30_000)
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('タイムアウト')
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
expect (api.startAPIDownload.mock.results[0].value).toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('retains the modal after acceptance failure without repeated POSTs', async () => {
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ job: 'job' })
|
||||||
|
.mockRejectedValueOnce (new Error ('offline'))
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
|
||||||
|
await flush (40_000)
|
||||||
|
expect (api.apiPost).toHaveBeenCalledTimes (2)
|
||||||
|
expect (api.startAPIDownload).not.toHaveBeenCalled ()
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('retries a temporary status transport failure without a second native GET', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValueOnce (new Error ('offline'))
|
||||||
|
.mockResolvedValue ({ ...ready, downloadAttempt: 'attempt', downloadStatus: 'started' })
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
await flush (2800)
|
||||||
|
expect (view.actions.close).toHaveBeenCalledTimes (1)
|
||||||
|
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('cancels an acceptance in flight and never starts the resulting receipt', async () => {
|
||||||
|
let resolve!: (value: typeof receipt) => void
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ job: 'job' }).mockImplementationOnce (
|
||||||
|
() => new Promise (complete => { resolve = complete }))
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
|
||||||
|
await act (async () => { resolve (receipt) })
|
||||||
|
await flush (1000)
|
||||||
|
expect (api.startAPIDownload).not.toHaveBeenCalled ()
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('releases an unmounted iframe and ignores late acknowledgements', async () => {
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
const release = api.startAPIDownload.mock.results[0].value
|
||||||
|
view.unmount ()
|
||||||
|
await flush ()
|
||||||
|
expect (release).toHaveBeenCalledTimes (1)
|
||||||
|
await flush (40_000)
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps authentication expiry and iframe errors visible', async () => {
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
act (() => { api.startAPIDownload.mock.calls[0][2] () })
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
|
||||||
|
await flush (40_000)
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
it ('shows the worker-specific reason instead of a download-success message', async () => {
|
||||||
|
api.apiPost.mockRejectedValueOnce ({ isAxiosError: true,
|
||||||
|
response: { status: 503, data: { error: 'export_worker_unavailable' } } })
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('ワーカーが稼働していません')
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
expect (await view.handle.cancel ()).toBe (true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('shows revoked authority and does not close after a successful acceptance', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValue ({ isAxiosError: true,
|
||||||
|
response: { status: 404 } })
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
await flush (800)
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('認可が失われたか')
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('stops after cancellation failure and permits an explicit cancellation retry', async () => {
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
|
||||||
|
await act (async () => { expect (await view.handle.cancel ()).toBe (false) })
|
||||||
|
const requests = api.apiGet.mock.calls.length
|
||||||
|
await flush (40_000)
|
||||||
|
expect (api.apiGet).toHaveBeenCalledTimes (requests)
|
||||||
|
expect (screen.getByRole ('alert')).toBeInTheDocument ()
|
||||||
|
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
|
||||||
|
expect (api.apiDelete).toHaveBeenCalledTimes (2)
|
||||||
|
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('ignores a different attempt even when it reports started', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValueOnce ({ ...ready,
|
||||||
|
downloadAttempt: 'another', downloadStatus: 'started' }).mockResolvedValue ({ ...ready,
|
||||||
|
downloadAttempt: 'attempt', downloadStatus: 'started' })
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
await flush (800)
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
await flush (800)
|
||||||
|
expect (view.actions.close).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps the created job available for cancellation retry during initial registration', async () => {
|
||||||
|
let resolve!: (value: { job: string }) => void
|
||||||
|
api.apiPost.mockImplementationOnce (() => new Promise (complete => { resolve = complete }))
|
||||||
|
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
let pending!: Promise<boolean>
|
||||||
|
act (() => { pending = view.handle.cancel () })
|
||||||
|
await act (async () => {
|
||||||
|
resolve ({ job: 'created-during-cancel' })
|
||||||
|
expect (await pending).toBe (false)
|
||||||
|
})
|
||||||
|
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
|
||||||
|
expect (api.apiDelete.mock.calls.map (call => call[0])).toEqual ([
|
||||||
|
'/counts/export/jobs/created-during-cancel', '/counts/export/jobs/created-during-cancel'])
|
||||||
|
expect (api.startAPIDownload).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('stops immediately if native download navigation cannot be created', async () => {
|
||||||
|
api.startAPIDownload.mockImplementationOnce (() => { throw new Error ('navigation blocked') })
|
||||||
|
const view = renderProgress ()
|
||||||
|
await flush ()
|
||||||
|
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
|
||||||
|
await flush (40_000)
|
||||||
|
expect (api.apiGet).toHaveBeenCalledTimes (1)
|
||||||
|
expect (view.actions.close).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
||||||
|
import { countsErrorMessage } from '@/lib/countsDisplay'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -26,178 +27,228 @@ const CountsExportProgress = (
|
|||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
const [state, setState] = useState<ExportStatus> ({
|
const [state, setState] = useState<ExportStatus> ({
|
||||||
status: 'starting', processed: 0, total: null })
|
status: 'starting', processed: 0, total: null })
|
||||||
const [cancelError, setCancelError] = useState (false)
|
const [cancelError, setCancelError] = useState<unknown> (null)
|
||||||
|
const [failure, setFailure] = useState<unknown> (null)
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null> (null)
|
||||||
|
// One mounted dialogue owns one immutable job, even if its parent rerenders.
|
||||||
|
const initial = useRef ({ payload, locale })
|
||||||
|
const actions = useRef (controls)
|
||||||
|
actions.current = controls
|
||||||
const terminal = useRef (false)
|
const terminal = useRef (false)
|
||||||
|
const finished = useRef (false)
|
||||||
|
const cancelAction = useRef<() => Promise<boolean>> (async () => false)
|
||||||
const cancelled = useRef (false)
|
const cancelled = useRef (false)
|
||||||
|
const generation = useRef (0)
|
||||||
const job = useRef<string | null> (null)
|
const job = useRef<string | null> (null)
|
||||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||||
const downloaded = useRef (false)
|
const registration = useRef<Promise<ExportStatus> | null> (null)
|
||||||
const registration = useRef (false)
|
|
||||||
const attempt = useRef<string | null> (null)
|
const attempt = useRef<string | null> (null)
|
||||||
const downloadDeadline = useRef (0)
|
const downloadDeadline = useRef (0)
|
||||||
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
|
|
||||||
const releaseDownload = useRef<(() => void) | null> (null)
|
const releaseDownload = useRef<(() => void) | null> (null)
|
||||||
const [downloadError, setDownloadError] = useState<string | null> (null)
|
const cancellation = useRef<Promise<boolean> | null> (null)
|
||||||
const pollRequest = useRef<AbortController | null> (null)
|
useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle])
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
handle.cancel = async () => {
|
const epoch = ++generation.current
|
||||||
if (terminal.current) return true
|
let active = true
|
||||||
|
let lastResponse = Date.now ()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let acknowledgementTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let request: AbortController | null = null
|
||||||
|
const current = () => active && generation.current === epoch
|
||||||
|
const stop = (reason: string) => {
|
||||||
|
if (!current () || terminal.current) return
|
||||||
|
terminal.current = true
|
||||||
|
if (timer != null) clearTimeout (timer)
|
||||||
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||||
|
request?.abort ()
|
||||||
releaseDownload.current?.()
|
releaseDownload.current?.()
|
||||||
|
releaseDownload.current = null
|
||||||
|
setDownloadError (reason)
|
||||||
|
}
|
||||||
|
const cancel = (): Promise<boolean> => {
|
||||||
|
if (finished.current || (terminal.current && job.current == null))
|
||||||
|
return Promise.resolve (true)
|
||||||
cancelled.current = true
|
cancelled.current = true
|
||||||
if (job.current != null)
|
terminal.current = true
|
||||||
{
|
if (timer != null) clearTimeout (timer)
|
||||||
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||||
|
request?.abort ()
|
||||||
|
releaseDownload.current?.()
|
||||||
|
releaseDownload.current = null
|
||||||
|
cancellation.current ??= (async () => {
|
||||||
const controller = new AbortController ()
|
const controller = new AbortController ()
|
||||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
setCancelError (false)
|
const created = await creation.current
|
||||||
|
if (created == null) return false
|
||||||
|
job.current = created.job
|
||||||
const result = await apiDelete<ExportStatus> (
|
const result = await apiDelete<ExportStatus> (
|
||||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
|
`/counts/export/jobs/${encodeURIComponent (created.job)}`, {
|
||||||
locale, signal: controller.signal })
|
locale: initial.current.locale, signal: controller.signal })
|
||||||
setState (result)
|
if (current ())
|
||||||
|
{
|
||||||
|
setState (result)
|
||||||
|
setCancelError (null)
|
||||||
|
finished.current = ['cancelled', 'failed'].includes (result.status)
|
||||||
|
terminal.current = finished.current
|
||||||
|
if (!terminal.current)
|
||||||
|
{
|
||||||
|
if (timer != null) clearTimeout (timer)
|
||||||
|
timer = setTimeout (() => poll (created.job), 800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['cancelled', 'failed'].includes (result.status)
|
||||||
}
|
}
|
||||||
catch
|
catch (error)
|
||||||
{
|
{
|
||||||
setCancelError (true)
|
if (isApiError (error) && error.response?.status === 404) return true
|
||||||
|
if (current ()) setCancelError (error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
clearTimeout (deadline)
|
clearTimeout (deadline)
|
||||||
|
cancellation.current = null
|
||||||
}
|
}
|
||||||
}
|
}) ()
|
||||||
return false
|
return cancellation.current
|
||||||
|
}
|
||||||
|
cancelAction.current = cancel
|
||||||
|
const armDeadline = () => {
|
||||||
|
if (downloadDeadline.current === 0) return
|
||||||
|
acknowledgementTimer = setTimeout (() => stop ('timeout'),
|
||||||
|
Math.max (0, downloadDeadline.current - Date.now ()))
|
||||||
}
|
}
|
||||||
}, [handle, locale])
|
|
||||||
useEffect (() => {
|
|
||||||
let active = true
|
|
||||||
let lastResponse = Date.now ()
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const poll = async (token: string) => {
|
const poll = async (token: string) => {
|
||||||
|
if (!current () || terminal.current) return
|
||||||
const controller = new AbortController ()
|
const controller = new AbortController ()
|
||||||
pollRequest.current = controller
|
request = controller
|
||||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await apiGet<ExportStatus> (
|
const result = await apiGet<ExportStatus> (
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||||
locale, signal: controller.signal })
|
locale: initial.current.locale, signal: controller.signal })
|
||||||
if (!active || terminal.current) return
|
if (!current () || terminal.current) return
|
||||||
const result = (cancelled.current && response.status === 'ready')
|
|
||||||
? await apiDelete<ExportStatus> (
|
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
|
||||||
locale, signal: controller.signal }) : response
|
|
||||||
if (!active || terminal.current) return
|
|
||||||
lastResponse = Date.now ()
|
lastResponse = Date.now ()
|
||||||
setState (result)
|
setState (result)
|
||||||
terminal.current = ['failed', 'cancelled'].includes (result.status)
|
if (cancelled.current)
|
||||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
|
||||||
{
|
{
|
||||||
registration.current = true
|
if (await cancel ()) return
|
||||||
const receipt = await apiPost<ExportStatus> (
|
}
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
else if (result.status === 'ready')
|
||||||
locale, signal: controller.signal })
|
{
|
||||||
if (!active || cancelled.current) return
|
if (result.token == null) { stop ('failed'); return }
|
||||||
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
|
if (attempt.current == null)
|
||||||
downloaded.current = true
|
{
|
||||||
attempt.current = receipt.downloadAttempt
|
registration.current ??= apiPost<ExportStatus> (
|
||||||
downloadDeadline.current = Date.now () + 30_000
|
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
||||||
acknowledgementTimer.current = setTimeout (() => {
|
locale: initial.current.locale, signal: controller.signal })
|
||||||
if (!active || terminal.current) return
|
const receipt = await registration.current
|
||||||
terminal.current = true
|
if (!current () || terminal.current || cancelled.current) return
|
||||||
pollRequest.current?.abort ()
|
if (receipt.downloadAttempt == null) { stop ('failed'); return }
|
||||||
releaseDownload.current?.()
|
attempt.current = receipt.downloadAttempt
|
||||||
setDownloadError ('timeout')
|
downloadDeadline.current = Date.now () + 30_000
|
||||||
}, 30_000)
|
armDeadline ()
|
||||||
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
|
try
|
||||||
if (active)
|
{
|
||||||
|
releaseDownload.current = startAPIDownload ('/counts/export', result.token,
|
||||||
|
() => stop ('failed'), new URLSearchParams ({
|
||||||
|
job: token, attempt: receipt.downloadAttempt }))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
stop ('failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.downloadAttempt === attempt.current)
|
||||||
|
{
|
||||||
|
if (result.downloadStatus === 'started')
|
||||||
{
|
{
|
||||||
terminal.current = true
|
terminal.current = true
|
||||||
setDownloadError ('failed')
|
finished.current = true
|
||||||
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||||
|
// Rack yielded a chunk; browser receipt and saving are not confirmed.
|
||||||
|
releaseDownload.current = null
|
||||||
|
actions.current.close ()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt }))
|
if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? ''))
|
||||||
}
|
{ stop (result.downloadStatus!); return }
|
||||||
if (attempt.current != null && result.downloadAttempt === attempt.current)
|
|
||||||
{
|
|
||||||
if (result.downloadStatus === 'started' && !cancelled.current)
|
|
||||||
{
|
|
||||||
terminal.current = true
|
|
||||||
// Native navigation continues after the dialogue unmounts.
|
|
||||||
releaseDownload.current = null
|
|
||||||
controls.close ()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (['failed', 'unavailable'].includes (result.downloadStatus ?? ''))
|
|
||||||
{
|
|
||||||
terminal.current = true
|
|
||||||
setDownloadError (result.downloadStatus!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (attempt.current != null && Date.now () > downloadDeadline.current)
|
else if (['failed', 'cancelled'].includes (result.status))
|
||||||
{
|
{
|
||||||
|
finished.current = true
|
||||||
terminal.current = true
|
terminal.current = true
|
||||||
setDownloadError ('timeout')
|
return
|
||||||
releaseDownload.current?.()
|
|
||||||
}
|
}
|
||||||
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
|
if (timer != null) clearTimeout (timer)
|
||||||
|
timer = setTimeout (() => poll (token), 800)
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
if (active && !terminal.current)
|
if (!current () || terminal.current) return
|
||||||
{
|
const status = isApiError (error) ? error.response?.status : null
|
||||||
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|
if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error)
|
||||||
|| Date.now () - lastResponse > 30_000)
|
if (registration.current != null && attempt.current == null)
|
||||||
{
|
{ stop ('failed'); return }
|
||||||
terminal.current = true
|
if (status != null && status >= 400 && status < 500)
|
||||||
setDownloadError ('unavailable')
|
{ stop ('unavailable'); return }
|
||||||
setState (previous => ({ ...previous, status: 'failed' }))
|
if (Date.now () - lastResponse >= 30_000)
|
||||||
return
|
{ stop ('timeout'); return }
|
||||||
}
|
timer = setTimeout (() => poll (token), 2000)
|
||||||
setCancelError (true)
|
|
||||||
timer = setTimeout (() => poll (token), 2000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
clearTimeout (deadline)
|
clearTimeout (deadline)
|
||||||
if (pollRequest.current === controller) pollRequest.current = null
|
if (request === controller) request = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (creation.current == null)
|
if (creation.current == null)
|
||||||
{
|
{
|
||||||
const controller = new AbortController ()
|
const controller = new AbortController ()
|
||||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||||
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
|
creation.current = apiPost<{ job: string }> (
|
||||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
'/counts/export/jobs', initial.current.payload, {
|
||||||
|
locale: initial.current.locale, signal: controller.signal })
|
||||||
|
.finally (() => clearTimeout (deadline))
|
||||||
}
|
}
|
||||||
creation.current.then (async result => {
|
armDeadline ()
|
||||||
if (!active || terminal.current) return
|
void creation.current.then (result => {
|
||||||
|
if (!current () || terminal.current) return
|
||||||
job.current = result.job
|
job.current = result.job
|
||||||
if (cancelled.current) await handle.cancel ()
|
void poll (result.job)
|
||||||
await poll (result.job)
|
}).catch (error => {
|
||||||
}).catch (() => {
|
if (current ()) setFailure (error)
|
||||||
if (active)
|
stop ('failed')
|
||||||
{
|
|
||||||
setDownloadError ('failed')
|
|
||||||
terminal.current = true
|
|
||||||
setState (previous => ({ ...previous, status: 'failed' }))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
if (timer != null) clearTimeout (timer)
|
if (timer != null) clearTimeout (timer)
|
||||||
pollRequest.current?.abort ()
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||||
releaseDownload.current?.()
|
request?.abort ()
|
||||||
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
|
// StrictMode immediately installs another effect; real unmount releases the frame.
|
||||||
|
queueMicrotask (() => {
|
||||||
|
if (generation.current !== epoch) return
|
||||||
|
releaseDownload.current?.()
|
||||||
|
releaseDownload.current = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [handle, locale])
|
}, [])
|
||||||
useEffect (() => {
|
useEffect (() => { controls.setActions ([]) }, [controls])
|
||||||
controls.setActions ([])
|
|
||||||
}, [controls])
|
|
||||||
const percent = state.total == null ? null : (state.total === 0 ? 100
|
const percent = state.total == null ? null : (state.total === 0 ? 100
|
||||||
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
||||||
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
|
const busy = downloadError == null && cancelError == null
|
||||||
const phase = message (`exportProgress.${state.status === 'running'
|
&& !['failed', 'cancelled'].includes (state.status)
|
||||||
? state.phase ?? 'running' : state.status}`)
|
const phaseKey = (downloadError != null || cancelError != null) ? 'failed'
|
||||||
|
: (state.status === 'running' ? state.phase ?? 'running'
|
||||||
|
: (state.status === 'ready' ? 'writing' : state.status))
|
||||||
|
const phase = message (`exportProgress.${phaseKey}`)
|
||||||
|
const failureMessage = countsErrorMessage (failure ?? state, locale)
|
||||||
|
const cancellationMessage = countsErrorMessage (cancelError, locale)
|
||||||
return <div className="space-y-3 py-2" aria-busy={busy}>
|
return <div className="space-y-3 py-2" aria-busy={busy}>
|
||||||
<p>{label ('exportProgress.format', {
|
<p>{label ('exportProgress.format', {
|
||||||
format: message (`exportFormats.${payload.format}`).text })}</p>
|
format: message (`exportFormats.${payload.format}`).text })}</p>
|
||||||
@@ -214,9 +265,14 @@ const CountsExportProgress = (
|
|||||||
? label ('exportProgress.processed', { count: state.processed })
|
? label ('exportProgress.processed', { count: state.processed })
|
||||||
: label ('exportProgress.measured', {
|
: label ('exportProgress.measured', {
|
||||||
processed: state.processed, total: state.total ?? 0, percent })}</p>
|
processed: state.processed, total: state.total ?? 0, percent })}</p>
|
||||||
{downloadError != null && <p role="alert">
|
{(downloadError != null || state.status === 'failed') && <p role="alert"
|
||||||
{label (`downloadErrors.${downloadError}`)}</p>}
|
lang={failureMessage.language}>
|
||||||
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
|
{failure != null || state.status === 'failed'
|
||||||
|
? failureMessage.text
|
||||||
|
: label (`downloadErrors.${downloadError}`)}
|
||||||
|
{downloadError != null && <><br/>{label ('errors.stopped')}</>}</p>}
|
||||||
|
{cancelError != null && <p role="alert" lang={cancellationMessage.language}>
|
||||||
|
{cancellationMessage.text}</p>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import type { CountSort } from '@/lib/counts'
|
|||||||
|
|
||||||
|
|
||||||
const CountsSortHeader = (
|
const CountsSortHeader = (
|
||||||
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
|
{ field, label, sorts, change, structured = false, rowSpan, colSpan,
|
||||||
|
resizable = true, dragId, totalKind, disabled = false }: {
|
||||||
field: string
|
field: string
|
||||||
label: { text: string; language: string }
|
label: { text: string; language: string }
|
||||||
sorts: CountSort[]
|
sorts: CountSort[]
|
||||||
change: (key: string, additive: boolean) => void
|
change: (key: string, additive: boolean) => void
|
||||||
structured?: boolean
|
structured?: boolean
|
||||||
rowSpan?: number; colSpan?: number; resizable?: boolean
|
rowSpan?: number; colSpan?: number; resizable?: boolean
|
||||||
dragId?: string; totalKind?: string },
|
dragId?: string; totalKind?: string; disabled?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const family = useContext (CountsTableScope)
|
const family = useContext (CountsTableScope)
|
||||||
@@ -84,7 +85,7 @@ const CountsSortHeader = (
|
|||||||
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
||||||
<div ref={heading} className="flex min-w-0 items-center">
|
<div ref={heading} className="flex min-w-0 items-center">
|
||||||
{shell?.handle}
|
{shell?.handle}
|
||||||
<Button type="button" variant="ghost" size="sm"
|
<Button type="button" variant="ghost" size="sm" disabled={disabled}
|
||||||
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
||||||
lang={aria.language} aria-label={aria.text} title={title.text}
|
lang={aria.language} aria-label={aria.text} title={title.text}
|
||||||
onPointerDown={event => {
|
onPointerDown={event => {
|
||||||
@@ -141,8 +142,9 @@ const CountsSortHeader = (
|
|||||||
</div>
|
</div>
|
||||||
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
||||||
</th>)
|
</th>)
|
||||||
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
|
return dragId == null ? render () : <SortableItem id={dragId}
|
||||||
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
|
name={message ('reorder', { column: label.text })} handleId={`column-${dragId}`}
|
||||||
|
moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { fireEvent, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
|
||||||
|
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
||||||
|
import { CountsDefinitionContext } from '@/lib/countsDefinitions'
|
||||||
|
import { postDefinition } from '@/test/fixtures/counts'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { CountsResult } from '@/lib/counts'
|
||||||
|
|
||||||
|
const result: CountsResult = {
|
||||||
|
count: 4, page: 1, limit: 100, groupCount: 1, outputCount: 1,
|
||||||
|
rows: [{ field: 'resource_id' }], cols: [], columnValuesEditable: false,
|
||||||
|
rowHeaders: [{ values: ['__counts_private__'], kind: 'data' },
|
||||||
|
{ values: [], kind: 'grand_total' }],
|
||||||
|
columnHeaders: [{ values: [], kind: 'grand_total' }],
|
||||||
|
cells: [{ values: ['__counts_private__'], count: 4 }],
|
||||||
|
pivotTotals: [{ row: [], column: [], count: 4 }], axisLabels: {} }
|
||||||
|
|
||||||
|
describe ('Counts summary privacy and row numbering', () => {
|
||||||
|
it ('copies masked labels and omits private links and legacy numbering', () => {
|
||||||
|
const { container } = renderWithProviders (
|
||||||
|
<CountsDefinitionContext.Provider value={postDefinition}>
|
||||||
|
<CountsTableLayout><CountsSummaryResults resource="posts" data={result}
|
||||||
|
params={new URLSearchParams ('mode=summary&row_numbers=1&rows=resource_id&cols=')}
|
||||||
|
pending={false} update={vi.fn ()}/></CountsTableLayout>
|
||||||
|
</CountsDefinitionContext.Provider>, { route: '/ja/counts/posts' })
|
||||||
|
const label = screen.getByText ('********')
|
||||||
|
expect (label).toHaveAttribute ('title', expect.stringContaining ('集計件数には含まれます'))
|
||||||
|
expect (label).toHaveClass ('text-red-600', 'dark:text-red-400')
|
||||||
|
const row = label.closest ('tr')!
|
||||||
|
expect (row.querySelector ('a')).toBeNull ()
|
||||||
|
expect (container.innerHTML).not.toContain ('__counts_private__')
|
||||||
|
expect (screen.queryByText ('No.')).not.toBeInTheDocument ()
|
||||||
|
expect (row.querySelectorAll ('td')).toHaveLength (2)
|
||||||
|
const table = screen.getByRole ('grid')
|
||||||
|
table.focus ()
|
||||||
|
fireEvent.keyDown (table, { key: 'Home' })
|
||||||
|
const setData = vi.fn ()
|
||||||
|
fireEvent.copy (table, { clipboardData: { setData } })
|
||||||
|
const plain = setData.mock.calls.find (entry => entry[0] === 'text/plain')?.[1]
|
||||||
|
expect (plain).toContain ('********')
|
||||||
|
expect (plain).not.toContain ('__counts_private__')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import SortableColumns from '@/components/common/SortableColumns'
|
import SortableColumns from '@/components/common/SortableColumns'
|
||||||
import { countsSummaryValue } from '@/lib/countsDisplay'
|
import { countsSummaryValue } from '@/lib/countsDisplay'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
|
||||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
import { changeCountSort, countsMaskedValue, countsRequestParams, readCountSort, writeCountSort }
|
||||||
|
from '@/lib/counts'
|
||||||
import { countsAxisLabel, useCountsDefinition }
|
import { countsAxisLabel, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
|
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState }
|
||||||
|
from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||||
@@ -21,12 +21,12 @@ const CountsSummaryTable = (
|
|||||||
{ resource, data, params, pending, update, dragIds = {} }: {
|
{ resource, data, params, pending, update, dragIds = {} }: {
|
||||||
dragIds?: Record<string, string>
|
dragIds?: Record<string, string>
|
||||||
resource: CountsResource; data: CountsResult; params: URLSearchParams
|
resource: CountsResource; data: CountsResult; params: URLSearchParams
|
||||||
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
|
pending: boolean
|
||||||
|
update: (key: string, value: string, extra?: Record<string, string>) => void },
|
||||||
) => {
|
) => {
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const numbered = false
|
|
||||||
const rows = data.rows ?? []
|
const rows = data.rows ?? []
|
||||||
const cols = data.cols ?? []
|
const cols = data.cols ?? []
|
||||||
const cells = data.cells ?? []
|
const cells = data.cells ?? []
|
||||||
@@ -49,7 +49,8 @@ const CountsSummaryTable = (
|
|||||||
const headingLevels = cols.length === 0 ? 1 : levels * 2
|
const headingLevels = cols.length === 0 ? 1 : levels * 2
|
||||||
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
||||||
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
||||||
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
const columnKey = (header: CountsPivotHeader) =>
|
||||||
|
`${header.kind}:${JSON.stringify (header.values)}`
|
||||||
const measures = readCountState<Record<string, string | number | null>[]> (
|
const measures = readCountState<Record<string, string | number | null>[]> (
|
||||||
params, 'measures', [])
|
params, 'measures', [])
|
||||||
const measureKey = (header: CountsPivotHeader) => {
|
const measureKey = (header: CountsPivotHeader) => {
|
||||||
@@ -66,13 +67,14 @@ const CountsSummaryTable = (
|
|||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
const nextSort = changeCountSort (sorts, key, additive)
|
const nextSort = changeCountSort (sorts, key, additive)
|
||||||
const active = new Set (nextSort.map (sort => sort.key))
|
const active = new Set (nextSort.map (sort => sort.key))
|
||||||
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
|
const records = measures.filter (record =>
|
||||||
|
record.key !== key && active.has (String (record.key)))
|
||||||
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
|
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
|
||||||
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
|
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
|
||||||
update ('sort', writeCountSort (nextSort),
|
update ('sort', writeCountSort (nextSort),
|
||||||
queryRecordParams (next, 'measures'))
|
queryRecordParams (next, 'measures'))
|
||||||
}
|
}
|
||||||
const keys = [...(numbered ? ['row_number'] : []),
|
const keys = [
|
||||||
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
||||||
...columnHeaders.map (columnKey)]
|
...columnHeaders.map (columnKey)]
|
||||||
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
|
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
|
||||||
@@ -86,22 +88,21 @@ const CountsSummaryTable = (
|
|||||||
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
||||||
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
const existing = readCountState<Record<string, string | number | null>[]> (params, 'conditions', [])
|
const existing = readCountState<Record<string, string | number | null>[]> (
|
||||||
|
params, 'conditions', [])
|
||||||
setCountState (next, 'conditions', encodeCountState ('conditions', [...existing,
|
setCountState (next, 'conditions', encodeCountState ('conditions', [...existing,
|
||||||
...row.values.map ((value, index) => ({ ...rows[index], operator: 'axis_eq', value })),
|
...row.values.map ((value, index) => ({ ...rows[index], operator: 'axis_eq', value })),
|
||||||
...column.values.map ((value, index) => ({ ...cols[index], operator: 'axis_eq', value }))]))
|
...column.values.map ((value, index) => ({
|
||||||
|
...cols[index], operator: 'axis_eq', value }))]))
|
||||||
next.set ('mode', 'details')
|
next.set ('mode', 'details')
|
||||||
next.delete ('sort')
|
next.delete ('sort')
|
||||||
next.delete ('page')
|
next.delete ('page')
|
||||||
return `/counts/${resource}?${next}`
|
return `/counts/${resource}?${next}`
|
||||||
}
|
}
|
||||||
let ordinal = (data.page - 1) * data.limit
|
|
||||||
const rowLabels = rowHeaders.map (header => {
|
const rowLabels = rowHeaders.map (header => {
|
||||||
const labels = names (header, rows, 0)
|
const labels = names (header, rows, 0)
|
||||||
// With no row axes the sole result row is not an additional rollup row.
|
|
||||||
const number = rows.length === 0 ? '1' : (header.kind === 'data' ? String (++ordinal) : '')
|
|
||||||
if (header.kind !== 'data') labels.push (aggregateLabel (header))
|
if (header.kind !== 'data') labels.push (aggregateLabel (header))
|
||||||
return [...(numbered ? [number] : []),
|
return [
|
||||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||||
})
|
})
|
||||||
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
|
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
|
||||||
@@ -121,11 +122,10 @@ const CountsSummaryTable = (
|
|||||||
return span
|
return span
|
||||||
}))
|
}))
|
||||||
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
|
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
|
||||||
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
|
if (span === 0) labels[column] = ''
|
||||||
}))
|
}))
|
||||||
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
||||||
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
|
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
|
||||||
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
|
||||||
...Array.from ({ length: leading }, (_, index) =>
|
...Array.from ({ length: leading }, (_, index) =>
|
||||||
level === 0 && rows[index] != null
|
level === 0 && rows[index] != null
|
||||||
? countsAxisLabel (rows[index], definition, locale).text : ''),
|
? countsAxisLabel (rows[index], definition, locale).text : ''),
|
||||||
@@ -142,7 +142,7 @@ const CountsSummaryTable = (
|
|||||||
return display (header.values[level], cols[level], rows.length + level)
|
return display (header.values[level], cols[level], rows.length + level)
|
||||||
})])
|
})])
|
||||||
const axisHeadingRows = cols.map ((axis, level) => [
|
const axisHeadingRows = cols.map ((axis, level) => [
|
||||||
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
|
...Array.from ({ length: leading }, () => ''),
|
||||||
...columnHeaders.map ((header, index) => {
|
...columnHeaders.map ((header, index) => {
|
||||||
if (header.values.length < level) return ''
|
if (header.values.length < level) return ''
|
||||||
const previous = columnHeaders[index - 1]
|
const previous = columnHeaders[index - 1]
|
||||||
@@ -155,7 +155,7 @@ const CountsSummaryTable = (
|
|||||||
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
|
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
|
||||||
if (cols.length > 0)
|
if (cols.length > 0)
|
||||||
{
|
{
|
||||||
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
|
for (let index = 0; index < leading; ++index)
|
||||||
{
|
{
|
||||||
headingRows[0][index] = valueHeadingRows[0][index]
|
headingRows[0][index] = valueHeadingRows[0][index]
|
||||||
headingRows[1][index] = ''
|
headingRows[1][index] = ''
|
||||||
@@ -214,6 +214,7 @@ const CountsSummaryTable = (
|
|||||||
const field = leaf ? columnKey (header) : sortKey
|
const field = leaf ? columnKey (header) : sortKey
|
||||||
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
||||||
colSpan={span} resizable={leaf}
|
colSpan={span} resizable={leaf}
|
||||||
|
disabled={header.values.includes (countsMaskedValue)}
|
||||||
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
|
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
|
||||||
totalKind={header.kind !== 'data' ? header.kind : undefined}
|
totalKind={header.kind !== 'data' ? header.kind : undefined}
|
||||||
dragId={level === 0 && header.values.length > 0
|
dragId={level === 0 && header.values.length > 0
|
||||||
@@ -235,7 +236,6 @@ const CountsSummaryTable = (
|
|||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
{headingRows.map ((_, tier) => <tr key={tier}>
|
{headingRows.map ((_, tier) => <tr key={tier}>
|
||||||
{tier === 0 && <>
|
{tier === 0 && <>
|
||||||
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
|
|
||||||
{Array.from ({ length: leading }, (_, index) => rows[index] == null
|
{Array.from ({ length: leading }, (_, index) => rows[index] == null
|
||||||
? <th key={index} rowSpan={headingLevels}/>
|
? <th key={index} rowSpan={headingLevels}/>
|
||||||
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
|
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
|
||||||
@@ -249,7 +249,7 @@ const CountsSummaryTable = (
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||||
{rowLabels[index].map ((value, offset) => {
|
{rowLabels[index].map ((value, offset) => {
|
||||||
const axis = offset - (numbered ? 1 : 0)
|
const axis = offset
|
||||||
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
|
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
|
||||||
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
|
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
|
||||||
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
||||||
@@ -259,8 +259,8 @@ const CountsSummaryTable = (
|
|||||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||||
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700">
|
||||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
|
{value === '********' ? <span
|
||||||
className="text-red-600 dark:text-red-400"
|
className="text-red-600 dark:text-red-400"
|
||||||
title={message ('maskedReason').text}>{value}</span> : value}</td>
|
title={message ('maskedReason').text}>{value}</span> : value}</td>
|
||||||
})}
|
})}
|
||||||
@@ -273,8 +273,9 @@ const CountsSummaryTable = (
|
|||||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||||
? '' : undefined}
|
? '' : undefined}
|
||||||
className="border px-2 py-1 dark:border-slate-700">
|
className="border px-2 py-1 dark:border-slate-700">
|
||||||
{[...row.values, ...column.values].includes ('__counts_private__')
|
{[...row.values, ...column.values].includes (countsMaskedValue)
|
||||||
? <span>{count (row, column)}</span> : <PrefetchLink to={drill (row, column)} aria-label={
|
? <span>{count (row, column)}</span>
|
||||||
|
: <PrefetchLink to={drill (row, column)} aria-label={
|
||||||
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
||||||
: `${message (totalLabels.get (
|
: `${message (totalLabels.get (
|
||||||
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
||||||
@@ -291,7 +292,8 @@ const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) =
|
|||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const headers = props.data.columnHeaders ?? []
|
const headers = props.data.columnHeaders ?? []
|
||||||
const columns = props.data.cols ?? []
|
const columns = props.data.cols ?? []
|
||||||
if (columns.length === 0) return <CountsSummaryTable {...props}/>
|
if (columns.length === 0 || props.data.columnValuesEditable === false)
|
||||||
|
return <CountsSummaryTable {...props}/>
|
||||||
const groups = [...new Set (headers.filter (header => header.values.length > 0)
|
const groups = [...new Set (headers.filter (header => header.values.length > 0)
|
||||||
.map (header => JSON.stringify (header.values.slice (0, 1))))]
|
.map (header => JSON.stringify (header.values.slice (0, 1))))]
|
||||||
const ids = groups.map ((_, index) => `group${index}`)
|
const ids = groups.map ((_, index) => `group${index}`)
|
||||||
|
|||||||
@@ -162,4 +162,40 @@ describe ('api helpers', () => {
|
|||||||
await apiPatch ('/posts/1')
|
await apiPatch ('/posts/1')
|
||||||
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||||
})
|
})
|
||||||
|
it ('aborts one write while a shared CSRF lookup remains pending for another write', async () => {
|
||||||
|
let complete!: (value: { data: { token: string } }) => void
|
||||||
|
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
|
||||||
|
const { apiPost } = await import ('@/lib/api')
|
||||||
|
const controller = new AbortController ()
|
||||||
|
const first = apiPost ('/counts/export/jobs', {}, { signal: controller.signal })
|
||||||
|
const second = apiPost ('/posts', {})
|
||||||
|
const rejected = expect (first).rejects.toMatchObject ({ name: 'AbortError' })
|
||||||
|
controller.abort ()
|
||||||
|
await rejected
|
||||||
|
complete ({ data: { token: 'shared' } })
|
||||||
|
await second
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledWith ('/users/csrf',
|
||||||
|
expect.objectContaining ({ timeout: 15_000 }))
|
||||||
|
expect (mocks.client.post).toHaveBeenCalledTimes (1)
|
||||||
|
expect (mocks.client.post.mock.calls[0][0]).toBe ('/posts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('releases native download frames after failure or explicit cancellation', async () => {
|
||||||
|
const { startAPIDownload } = await import ('@/lib/api')
|
||||||
|
const failure = vi.fn ()
|
||||||
|
const release = startAPIDownload ('/counts/export', 'file', failure,
|
||||||
|
new URLSearchParams ({ job: 'job', attempt: 'attempt' }))
|
||||||
|
const frame = document.querySelector ('iframe')!
|
||||||
|
expect (new URL (frame.src).searchParams.get ('attempt')).toBe ('attempt')
|
||||||
|
frame.dispatchEvent (new Event ('error'))
|
||||||
|
frame.dispatchEvent (new Event ('error'))
|
||||||
|
expect (failure).toHaveBeenCalledTimes (1)
|
||||||
|
expect (frame).not.toBeInTheDocument ()
|
||||||
|
release ()
|
||||||
|
const cancel = startAPIDownload ('/counts/export', 'file', failure)
|
||||||
|
cancel ()
|
||||||
|
expect (document.querySelector ('iframe')).toBeNull ()
|
||||||
|
expect (failure).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
+42
-10
@@ -63,7 +63,22 @@ const withSession = (opt?: Opt): AxiosRequestConfig => {
|
|||||||
|
|
||||||
|
|
||||||
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
|
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
|
||||||
const token = await csrfToken ()
|
const signal = opt?.signal
|
||||||
|
if (signal?.aborted) throw new DOMException ('Aborted', 'AbortError')
|
||||||
|
const pending = csrfToken ()
|
||||||
|
// A shared CSRF lookup must not make an individual aborted write wait forever.
|
||||||
|
const token = signal == null ? await pending : await new Promise<string> ((resolve, reject) => {
|
||||||
|
const abort = () => reject (new DOMException ('Aborted', 'AbortError'))
|
||||||
|
signal.addEventListener ('abort', abort, { once: true })
|
||||||
|
if (signal.aborted) abort ()
|
||||||
|
void pending.then (value => {
|
||||||
|
signal.removeEventListener ('abort', abort)
|
||||||
|
resolve (value)
|
||||||
|
}, error => {
|
||||||
|
signal.removeEventListener ('abort', abort)
|
||||||
|
reject (error)
|
||||||
|
})
|
||||||
|
})
|
||||||
const config = withSession (opt)
|
const config = withSession (opt)
|
||||||
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
|
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
|
||||||
}
|
}
|
||||||
@@ -73,7 +88,8 @@ const csrfToken = async (): Promise<string> => {
|
|||||||
const state = csrfState ()
|
const state = csrfState ()
|
||||||
if (state.token != null)
|
if (state.token != null)
|
||||||
return state.token
|
return state.token
|
||||||
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
|
state.pending ??= client.get<{ token: string }> (
|
||||||
|
'/users/csrf', { ...withSession (), timeout: 15_000 })
|
||||||
.then (response => {
|
.then (response => {
|
||||||
if (csrfState () !== state)
|
if (csrfState () !== state)
|
||||||
return csrfToken ()
|
return csrfToken ()
|
||||||
@@ -223,19 +239,35 @@ export const startAPIDownload = (
|
|||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
const frame = document.createElement ('iframe')
|
const frame = document.createElement ('iframe')
|
||||||
frame.hidden = true
|
frame.hidden = true
|
||||||
|
let timer: ReturnType<typeof window.setTimeout> | null = null
|
||||||
|
let closed = false
|
||||||
|
const release = () => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
if (timer != null) window.clearTimeout (timer)
|
||||||
|
frame.remove ()
|
||||||
|
}
|
||||||
|
const failure = () => {
|
||||||
|
if (closed) return
|
||||||
|
release ()
|
||||||
|
onError?.()
|
||||||
|
}
|
||||||
frame.addEventListener ('load', () => {
|
frame.addEventListener ('load', () => {
|
||||||
const document = frame.contentDocument
|
try
|
||||||
if (document != null && document.URL !== 'about:blank')
|
|
||||||
{
|
{
|
||||||
onError?.()
|
const content = frame.contentDocument
|
||||||
frame.remove ()
|
// Attachment navigations normally have no document. An error document is failure.
|
||||||
|
if (content != null && content.URL !== 'about:blank') failure ()
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Cross-origin navigation cannot be inspected; the authenticated ACK decides.
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
|
frame.addEventListener ('error', failure)
|
||||||
const suffix = query == null ? '' : `?${query.toString ()}`
|
const suffix = query == null ? '' : `?${query.toString ()}`
|
||||||
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
|
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
|
||||||
document.body.append (frame)
|
document.body.append (frame)
|
||||||
// Attachments do not reliably fire load; keep this isolated from page navigation.
|
timer = window.setTimeout (release, 10 * 60_000)
|
||||||
const timer = window.setTimeout (() => frame.remove (), 10 * 60_000)
|
return release
|
||||||
return () => { window.clearTimeout (timer); frame.remove () }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
|
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
|
||||||
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
|
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
|
||||||
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
|
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
|
||||||
from '@/lib/countsDisplay'
|
from '@/lib/countsDisplay'
|
||||||
import { countsClipboard } from '@/lib/countsClipboard'
|
import { countsClipboard } from '@/lib/countsClipboard'
|
||||||
import { createUII18n, translateUIMessage } from '@/lib/i18n'
|
import { createUII18n, translateUIMessage } from '@/lib/i18n'
|
||||||
@@ -157,3 +157,24 @@ describe ('Counts logical display and exports', () => {
|
|||||||
expect (isCountsPath (path.split ('?')[0])).toBe (true)
|
expect (isCountsPath (path.split ('?')[0])).toBe (true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe ('Counts safe failure messages', () => {
|
||||||
|
it.each ([
|
||||||
|
[400, { error: 'too_many_axes', max_axes: 8 }, '最大8個'],
|
||||||
|
[422, { error: 'too_many_columns', max_columns: 73 }, '最大73列'],
|
||||||
|
[503, { error: 'counts_query_timeout' }, '時間制限'],
|
||||||
|
[503, { error: 'export_worker_unavailable' }, 'ワーカー'],
|
||||||
|
[400, {}, '入力条件'], [401, {}, '認証状態'], [403, {}, '権限']]) (
|
||||||
|
'distinguishes HTTP %s with a known safe response', (status, data, expected) => {
|
||||||
|
const failure = { isAxiosError: true, response: { status, data } }
|
||||||
|
expect (countsErrorMessage (failure, 'ja').text).toContain (expected)
|
||||||
|
})
|
||||||
|
it ('never renders arbitrary exception messages or private identifiers', () => {
|
||||||
|
const failure = { isAxiosError: true, response: { status: 500,
|
||||||
|
data: { error: 'SELECT private_title FROM secret_table', message: 'private-user-17' } } }
|
||||||
|
const text = countsErrorMessage (failure, 'ja').text
|
||||||
|
expect (text).not.toMatch (/SELECT|private|secret_table/)
|
||||||
|
expect (countsErrorMessage ({ error: 'worker_stalled' }, 'ja').text).toContain ('停止')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
|||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { TagNameSnapshot } from '@/types'
|
import type { TagNameSnapshot } from '@/types'
|
||||||
|
|
||||||
|
export const countsMaskedValue = '__counts_private__'
|
||||||
|
|
||||||
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
|
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
|
||||||
| 'tag_descriptions' | 'tag_groups' | 'materials'
|
| 'tag_descriptions' | 'tag_groups' | 'materials'
|
||||||
export type CountsName = TagNameSnapshot & {
|
export type CountsName = TagNameSnapshot & {
|
||||||
@@ -77,6 +79,7 @@ export type CountsResult = {
|
|||||||
executionAt?: string
|
executionAt?: string
|
||||||
columnValues?: (string | number | null)[][]
|
columnValues?: (string | number | null)[][]
|
||||||
cells?: { values: (string | number | null)[]; count: number }[]
|
cells?: { values: (string | number | null)[]; count: number }[]
|
||||||
|
columnValuesEditable?: boolean
|
||||||
conditionLabels?: Record<string, Record<string, string | null>>
|
conditionLabels?: Record<string, Record<string, string | null>>
|
||||||
axisLabels?: Record<string, Record<string, string | null>>
|
axisLabels?: Record<string, Record<string, string | null>>
|
||||||
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
import { isApiError } from '@/lib/api'
|
||||||
|
import { countsMaskedValue, fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||||
sortedCountChildren } from '@/lib/counts'
|
sortedCountChildren } from '@/lib/counts'
|
||||||
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
|
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
|
||||||
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
|
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
|
||||||
@@ -12,6 +13,37 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
|||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
|
||||||
|
// Render only known codes and numerical limits, never arbitrary server error text.
|
||||||
|
export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
|
||||||
|
const response = isApiError (failure) ? failure.response : null
|
||||||
|
const body = (response?.data ?? failure) as {
|
||||||
|
error?: unknown; max_axes?: unknown; maxAxes?: unknown
|
||||||
|
max_columns?: unknown; maxColumns?: unknown } | null
|
||||||
|
const code = body != null && typeof body === 'object' ? body.error : null
|
||||||
|
const message = (key: string, values?: Record<string, string | number>) =>
|
||||||
|
translateUIMessage (key, 'counts', locale, values)
|
||||||
|
if (code === 'too_many_axes' || code === 'too_many_columns')
|
||||||
|
{
|
||||||
|
const maximum = code === 'too_many_axes' ? body?.maxAxes ?? body?.max_axes
|
||||||
|
: body?.maxColumns ?? body?.max_columns
|
||||||
|
if (typeof maximum === 'number')
|
||||||
|
return message (code === 'too_many_axes' ? 'axisLimit' : 'columnLimit.message', {
|
||||||
|
max: maximum })
|
||||||
|
}
|
||||||
|
if (code === 'private_condition') return message ('privateCondition')
|
||||||
|
if (code === 'counts_query_timeout' || code === 'generation_timeout')
|
||||||
|
return message ('errors.queryTimeout')
|
||||||
|
if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
|
||||||
|
return message ('errors.workerUnavailable')
|
||||||
|
if (['worker_stalled', 'interrupted', 'generation_failed', 'export_too_large',
|
||||||
|
'export_storage_full'].includes (String (code))) return message ('errors.stopped')
|
||||||
|
if (response?.status === 401) return message ('errors.authentication')
|
||||||
|
if (response?.status === 403) return message ('errors.permission')
|
||||||
|
if (response?.status === 400 || response?.status === 422) return message ('errors.input')
|
||||||
|
return message ('common:fetchError')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
|
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
|
||||||
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
|
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
|
||||||
|
|
||||||
@@ -450,6 +482,7 @@ export const countsConditionValue = (
|
|||||||
axis: CountsAxis, locale: LocaleCode, timezone: string,
|
axis: CountsAxis, locale: LocaleCode, timezone: string,
|
||||||
labels?: Record<string, Record<string, string | null>>,
|
labels?: Record<string, Record<string, string | null>>,
|
||||||
): string => {
|
): string => {
|
||||||
|
if (value === countsMaskedValue) return '********'
|
||||||
if (axis.field === 'user' && value == null)
|
if (axis.field === 'user' && value == null)
|
||||||
return translateUIMessage ('bot', 'counts', locale).text
|
return translateUIMessage ('bot', 'counts', locale).text
|
||||||
if (value == null) return 'NULL'
|
if (value == null) return 'NULL'
|
||||||
@@ -488,7 +521,7 @@ export const countsSummaryValue = (
|
|||||||
): string => {
|
): string => {
|
||||||
const message = (key: string, values?: Record<string, string | number>) =>
|
const message = (key: string, values?: Record<string, string | number>) =>
|
||||||
translateUIMessage (key, 'counts', locale, values).text
|
translateUIMessage (key, 'counts', locale, values).text
|
||||||
if (value === '__counts_private__') return '********'
|
if (value === countsMaskedValue) return '********'
|
||||||
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
||||||
if (['language_code', 'source_language_code'].includes (axis.field))
|
if (['language_code', 'source_language_code'].includes (axis.field))
|
||||||
return languageUIMessage (String (value), locale).text
|
return languageUIMessage (String (value), locale).text
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"maskedReason",
|
"maskedReason",
|
||||||
"downloadErrors",
|
"downloadErrors",
|
||||||
"axisLimit",
|
"axisLimit",
|
||||||
"privateCondition"
|
"privateCondition",
|
||||||
|
"errors"
|
||||||
],
|
],
|
||||||
"outputRows": "行数:{{value}} 件",
|
"outputRows": "行数:{{value}} 件",
|
||||||
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
||||||
@@ -15,5 +16,13 @@
|
|||||||
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||||
},
|
},
|
||||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
|
||||||
|
"errors": {
|
||||||
|
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
|
||||||
|
"workerUnavailable": "エクスポートワーカーが稼働していません。",
|
||||||
|
"stopped": "エクスポート処理が停止しました。再実行してください。",
|
||||||
|
"input": "入力条件が正しくありません。",
|
||||||
|
"permission": "この操作を実行する権限がありません。",
|
||||||
|
"authentication": "認証状態を確認して再実行してください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,5 +299,13 @@
|
|||||||
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||||
},
|
},
|
||||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
|
||||||
|
"errors": {
|
||||||
|
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
|
||||||
|
"workerUnavailable": "エクスポートワーカーが稼働していません。",
|
||||||
|
"stopped": "エクスポート処理が停止しました。再実行してください。",
|
||||||
|
"input": "入力条件が正しくありません。",
|
||||||
|
"permission": "この操作を実行する権限がありません。",
|
||||||
|
"authentication": "認証状態を確認して再実行してください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const renderPage = (extra = '') => renderWithProviders (<>
|
|||||||
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
|
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
|
||||||
|
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
|
|
||||||
fetchCounts.mockReset ().mockResolvedValue (detailResult)
|
fetchCounts.mockReset ().mockResolvedValue (detailResult)
|
||||||
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
|
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
|
||||||
})
|
})
|
||||||
@@ -104,4 +103,22 @@ describe ('Counts execution and URL state', () => {
|
|||||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
|
expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
|
||||||
expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
|
expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
|
||||||
})
|
})
|
||||||
|
it ('can execute after an initial column-limit failure with no execution timestamp', async () => {
|
||||||
|
fetchCounts.mockRejectedValueOnce ({ isAxiosError: true,
|
||||||
|
response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } })
|
||||||
|
renderPage ()
|
||||||
|
await screen.findByRole ('alert')
|
||||||
|
execute ()
|
||||||
|
await screen.findByText ('Recorded title')
|
||||||
|
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
|
||||||
|
expect (fetchCounts).toHaveBeenCalledTimes (2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps the page heading and common site title consistent', async () => {
|
||||||
|
renderPage ()
|
||||||
|
await screen.findByText ('Recorded title')
|
||||||
|
await waitFor (() => expect (document.title).toContain ('広場集計 | '))
|
||||||
|
expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
|
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
|
||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||||
visibleCountsResources, countsFieldLabel,
|
visibleCountsResources, countsFieldLabel,
|
||||||
@@ -231,13 +231,7 @@ const CountsResourceContent = (
|
|||||||
|| pending || fetching || placeholder
|
|| pending || fetching || placeholder
|
||||||
|| (!params.has ('execution_at') && !errorFlg)))
|
|| (!params.has ('execution_at') && !errorFlg)))
|
||||||
const failure = runError ? runFailure : queryError
|
const failure = runError ? runFailure : queryError
|
||||||
const errorBody = isApiError (failure) ? failure.response?.data as {
|
const failureMessage = countsErrorMessage (failure, locale)
|
||||||
error?: string; max_axes?: number; maxAxes?: number } : undefined
|
|
||||||
const errorCode = errorBody?.error
|
|
||||||
const failureMessage = errorCode === 'too_many_axes'
|
|
||||||
? label ('axisLimit', { max: errorBody?.maxAxes ?? errorBody?.max_axes ?? '' })
|
|
||||||
: (errorCode === 'private_condition' ? label ('privateCondition')
|
|
||||||
: label ('common:fetchError'))
|
|
||||||
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,7 +349,7 @@ const CountsResourceContent = (
|
|||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
conditionLabels={errorFlg ? undefined : data?.conditionLabels}
|
conditionLabels={errorFlg ? undefined : data?.conditionLabels}
|
||||||
conditionLabelsParams={resultParams.toString ()}
|
conditionLabelsParams={resultParams.toString ()}
|
||||||
pending={running || fetching || !params.has ('execution_at')}
|
pending={running || fetching}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
reorder={value => update ('order', value)}
|
reorder={value => update ('order', value)}
|
||||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||||
@@ -381,7 +375,7 @@ const CountsResourceContent = (
|
|||||||
<p>{label ('columnLimit.title')}</p>
|
<p>{label ('columnLimit.title')}</p>
|
||||||
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
|
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
|
||||||
</div> : ((definitionError || errorFlg || runError)
|
</div> : ((definitionError || errorFlg || runError)
|
||||||
&& <p role="alert">{failureMessage}</p>)}
|
&& <p role="alert" lang={failureMessage.language}>{failureMessage.text}</p>)}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
aria-busy={waiting}>
|
aria-busy={waiting}>
|
||||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
|
||||||
|
})
|
||||||
|
|
||||||
afterEach (() => {
|
afterEach (() => {
|
||||||
cleanup ()
|
cleanup ()
|
||||||
@@ -16,10 +20,10 @@ window.scroll = function () {
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.requestAnimationFrame = callback => {
|
// Animation callbacks must advance time and must not recurse synchronously.
|
||||||
callback (0)
|
window.requestAnimationFrame = callback => window.setTimeout (
|
||||||
return 0
|
() => callback (performance.now ()), 16)
|
||||||
}
|
window.cancelAnimationFrame = identifier => window.clearTimeout (identifier)
|
||||||
|
|
||||||
Object.defineProperty (window, 'matchMedia', {
|
Object.defineProperty (window, 'matchMedia', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする