323 行
16 KiB
Ruby
323 行
16 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Counts table API', type: :request do
|
|
include_context 'Ainu locale'
|
|
|
|
let!(:actor) { create(:user, :member, name: 'Counts actor') }
|
|
let!(:record) { create(:post) }
|
|
let!(:tag) { create(:tag, primary_name: 'alpha', category: :general) }
|
|
let!(:external) { create(:external_tag, name: 'beta') }
|
|
let(:point) { '2025-01-01T00:00:00Z' }
|
|
let(:base) do
|
|
{ resource: 'posts', definition: 'history', mode: 'details',
|
|
execution_at: point, timezone: 'UTC', locale: 'ja' }
|
|
end
|
|
|
|
before do
|
|
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha',
|
|
category: 'general', aliases: '', parent_tag_ids: '',
|
|
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
|
created_at: Time.utc(2023))
|
|
NicoTagVersion.create!(external_tag: external, version_no: 1,
|
|
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
|
|
snapshots = [
|
|
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
|
|
{ external_tag_id: external.id }]
|
|
[1, 2, 3].each do |number|
|
|
PostVersion.create!(post: record, version_no: number,
|
|
event_type: number == 1 ? 'create' : 'update', url: record.url,
|
|
title: "snapshot #{ number }", tags: '', parent_post_ids: '',
|
|
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
|
|
created_by_user: number == 3 ? nil : actor,
|
|
created_at: Time.utc(2024, 1, 31, 15) + (number - 1).hours)
|
|
end
|
|
end
|
|
|
|
def query(extra = {})
|
|
get '/counts/query', params: base.merge(extra)
|
|
expect(response).to have_http_status(:ok)
|
|
response.parsed_body
|
|
end
|
|
|
|
def axis(field, value, type: 'string', grain: nil, root: 'conditions')
|
|
result = { root => '', "#{ root }.0.field" => field,
|
|
"#{ root }.0.value_type" => type }
|
|
result["#{ root }.0.operator"] = 'axis_eq' if root == 'conditions'
|
|
result["#{ root }.0.value"] = value unless type == 'null'
|
|
result["#{ root }.0.grain"] = grain if grain
|
|
result
|
|
end
|
|
|
|
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
|
|
|
|
it 'separates latest and history and defaults to stable detail ordering and limit 100' do
|
|
history = query
|
|
expect(versions(history)).to eq([1, 2, 3])
|
|
expect(history).to include('limit' => 100, 'count' => 3, 'output_count' => 3,
|
|
'visible_output_count' => 3, 'excluded_count' => 0)
|
|
expect(history['versions'].map { _1['latest'] }).to eq([false, false, true])
|
|
expect(versions(query(definition: 'latest'))).to eq([3])
|
|
expect(versions(query(limit: 1, page: 2))).to eq([2])
|
|
expect(versions(query(sort: 'version_no:desc', limit: 1))).to eq([3])
|
|
end
|
|
|
|
it 'counts regular pivot rows separately from totals and from underlying records' do
|
|
total = query(mode: 'summary', rows: '', cols: '')
|
|
expect(total).to include('count' => 3, 'output_count' => 1, 'group_count' => 1)
|
|
expect(total['cells']).to eq([{ 'values' => [], 'count' => 3 }])
|
|
pivot = query(mode: 'summary', rows: 'event_type', cols: '', sort: '', limit: 1)
|
|
expect(pivot).to include('group_count' => 2, 'visible_output_count' => 2)
|
|
expect(pivot['row_headers'].count { _1['kind'] == 'data' }).to eq(1)
|
|
expect(pivot['row_headers']).to include(hash_including('kind' => 'grand_total'))
|
|
expect(query(mode: 'summary', rows: '', cols: 'event_type', sort: '')['output_count']).to eq(1)
|
|
end
|
|
|
|
it 'preserves timezone, NULL and legacy drill semantics and permits condition removal' do
|
|
month = axis('created_at', '2024-02', grain: 'month')
|
|
expect(versions(query(month.merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
|
|
expect(versions(query(month))).to eq([])
|
|
old = axis('created_at', '2024-02', grain: 'month', root: 'drill')
|
|
expect(versions(query(old.merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
|
|
expect(versions(query(old.merge(month).merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
|
|
expect(versions(query(axis('user', nil, type: 'null')))).to eq([3])
|
|
expect(versions(query(axis('changed_field', 'title')))).to eq([2, 3])
|
|
expect(versions(query)).to eq([1, 2, 3])
|
|
end
|
|
|
|
it 'evaluates latest before filtering the version set' do
|
|
predicate = { conditions: '', 'conditions.0.field' => 'latest',
|
|
'conditions.0.operator' => 'eq', 'conditions.0.value' => 'false' }
|
|
expect(versions(query(predicate))).to eq([1, 2])
|
|
expect(versions(query(predicate.merge('conditions.0.value' => 'true')))).to eq([3])
|
|
end
|
|
|
|
it 'keeps ANY, ALL and NONE distinct without multiplying parent records' do
|
|
values = { conditions: '', 'conditions.0.field' => 'tags',
|
|
'conditions.0.values' => '1', condition_values: '',
|
|
'condition_values.0.condition' => '0',
|
|
'condition_values.0.value_type' => 'string',
|
|
'condition_values.0.value' => "internal:#{ tag.id }",
|
|
'condition_values.1.condition' => '0',
|
|
'condition_values.1.value_type' => 'string',
|
|
'condition_values.1.value' => "external:#{ external.id }" }
|
|
{ 'any' => [1, 2], 'all' => [1], 'none' => [3] }.each do |operator, expected|
|
|
expect(versions(query(values.merge('conditions.0.operator' => operator)))).to eq(expected)
|
|
end
|
|
result = query(axis('tags', "internal:#{ tag.id }"))
|
|
expect(result['versions'].first['tags'].length).to eq(2)
|
|
end
|
|
|
|
it 'distinguishes IN, NOT IN, an empty selection and an unspecified selection' do
|
|
predicate = { conditions: '', 'conditions.0.field' => 'version_no',
|
|
'conditions.0.operator' => 'in', 'conditions.0.values' => '1',
|
|
condition_values: '', 'condition_values.0.condition' => '0',
|
|
'condition_values.0.value_type' => 'number',
|
|
'condition_values.0.value' => '1' }
|
|
expect(versions(query(predicate))).to eq([1])
|
|
expect(versions(query(predicate.merge('conditions.0.operator' => 'not_in')))).to eq([2, 3])
|
|
empty = predicate.reject { |key, _value| key.to_s.start_with?('condition_values.') }
|
|
expect(versions(query(empty))).to be_empty
|
|
expect(versions(query(empty.except('conditions.0.values')))).to eq([1, 2, 3])
|
|
end
|
|
|
|
it 'resolves current tag names for AND, OR and negative searches' do
|
|
expect(versions(query(tags: 'alpha'))).to eq([1, 2])
|
|
expect(versions(query(tags: 'alpha nico:beta'))).to eq([1])
|
|
expect(versions(query(tags: 'nico:beta alpha'))).to eq([1])
|
|
expect(versions(query(tags: 'alpha not:nico:beta'))).to eq([2])
|
|
expect(versions(query(tags: 'alpha nico:beta', match: 'any'))).to eq([1, 2])
|
|
expect(versions(query(tags: 'not:alpha'))).to eq([3])
|
|
end
|
|
|
|
context 'when current tag names differ from recorded snapshot names' do
|
|
before do
|
|
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
|
|
.update!(name: 'renamed-alpha')
|
|
create(:tag_name, :alias, tag:, name: 'alpha-alias')
|
|
TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
|
|
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
|
|
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
|
|
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
|
|
created_at: Time.utc(2024, 2, 1))
|
|
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
|
|
url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
|
|
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
|
|
category: 'general', sections: [] }],
|
|
created_by_user: actor, created_at: Time.utc(2024, 2, 2))
|
|
end
|
|
|
|
it 'resolves current names and aliases to stable IDs in both definitions' do
|
|
{ 'history' => [1, 2, 4], 'latest' => [4] }.each do |definition, expected|
|
|
['renamed-alpha', 'alpha-alias'].each do |name|
|
|
expect(versions(query(tags: name, definition:))).to eq(expected)
|
|
end
|
|
end
|
|
result = query(tags: 'renamed-alpha')
|
|
expect(result['versions'].first['tags'].first['name']).to eq('alpha')
|
|
expect(versions(query(tags: 'renamed-alpha', definition: 'latest',
|
|
at: '2024-01-31T16:00:00Z'))).to eq([2])
|
|
expect(versions(query(tags: 'not:alpha-alias'))).to eq([3])
|
|
expect(versions(query(tags: 'renamed-alpha nico:beta'))).to eq([1])
|
|
expect(versions(query(tags: 'renamed-alpha nico:beta', match: 'any'))).to eq([1, 2, 4])
|
|
end
|
|
|
|
it 'does not match a historical name reassigned to another tag ID' do
|
|
create(:tag, primary_name: 'alpha')
|
|
expect(versions(query(tags: 'alpha'))).to eq([])
|
|
expect(versions(query(tags: 'unregistered-name'))).to eq([])
|
|
expect(versions(query(tags: 'not:unregistered-name'))).to eq([1, 2, 3, 4])
|
|
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
|
|
end
|
|
|
|
it 'resolves external names from current registrations too' do
|
|
external.update!(name: 'renamed-beta')
|
|
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
|
|
expect(versions(query(tags: 'nico:beta'))).to eq([])
|
|
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
|
|
end
|
|
end
|
|
|
|
it 'rejects malformed structured conditions and axes without accepting arbitrary SQL' do
|
|
[
|
|
{ rows: '[{"field":"user"}]' }, { rows: 'user user' },
|
|
{ rows: 'user:month' }, { rows: 'password' }, { limit: 101 }, { limit: 0 },
|
|
{ page: '1x' }, { conditions: 'eyJmaWVsZCI6InVzZXIifQ==' },
|
|
{ conditions: '', 'conditions.1.field' => 'user' },
|
|
axis('password', '1'), axis('changed_field', 'title').merge(definition: 'latest'),
|
|
axis('user', 'yes', type: 'boolean'),
|
|
{ conditions: '', 'conditions.0.field' => 'title',
|
|
'conditions.0.operator' => 'eq', 'conditions.0.value' => 'x' * 8193 }].each do |invalid|
|
|
get '/counts/query', params: base.merge(invalid)
|
|
expect(response).to have_http_status(:bad_request), invalid.inspect
|
|
end
|
|
end
|
|
|
|
it 'rejects duplicate URL keys and JSON query payloads at the HTTP boundary' do
|
|
['resource=posts&tag_refs=', 'resource=posts&tag_refs.0.tag_id=1',
|
|
'resource=posts&limit=20&limit=50', 'resource=posts&headers=%5B%22Id.%22%5D',
|
|
'resource=posts&limit%5B%5D=20'].each do |query_string|
|
|
get "/counts/query?#{ query_string }"
|
|
expect(response).to have_http_status(:bad_request)
|
|
end
|
|
end
|
|
|
|
it 'reports column overflow separately from malformed input' do
|
|
101.times do |index|
|
|
PostVersion.create!(post: record, version_no: index + 4,
|
|
event_type: 'update', url: record.url, title: "column #{ index }",
|
|
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
|
end
|
|
get '/counts/query', params: base.merge(mode: 'summary', rows: '', cols: 'title', sort: '')
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body).to eq('error' => 'too_many_columns', 'max_columns' => 100)
|
|
end
|
|
|
|
it 'defines all six resources with filterable output fields and shared detail defaults' do
|
|
locale
|
|
CountsResourceDefinition::DEFINITIONS.each_key do |resource|
|
|
get "/counts/definitions/#{ resource }", params: { locale: 'ain-Kana' }
|
|
expect(response).to have_http_status(:ok)
|
|
body = response.parsed_body
|
|
filters = body['filter_fields'].map { _1['key'] }
|
|
walk = lambda do |fields, prefix|
|
|
fields.each do |field|
|
|
key = [prefix, field['key']].compact.join('.')
|
|
expect(filters).to include(key)
|
|
walk.call(field.fetch('children', []), key)
|
|
end
|
|
end
|
|
walk.call(body['output_fields'], nil)
|
|
defaults = body['defaults'].select { _1['mode'] == 'details' }
|
|
expect(defaults.map { _1['definition'] }).to eq(['latest', 'history'])
|
|
expect(defaults.first['params']).to eq(defaults.last['params'])
|
|
settings = defaults.first['params'].to_h { [_1['key'], _1['value']] }
|
|
expect(settings['sort']).to eq('resource_id:asc version_no:asc')
|
|
if resource == 'posts'
|
|
expect(settings['children'].split).to eq(['tags.name_ain', 'parents.id'])
|
|
end
|
|
keys = body['name_columns'].map { _1['key'] }
|
|
languages = Language.order(:created_at, :code).pluck(:code)
|
|
expect(keys).to eq(languages.map { "name_#{ _1 }" } + languages.map { "aliases_#{ _1 }" })
|
|
end
|
|
end
|
|
|
|
[nil, :guest, :member, :admin].each do |role|
|
|
context "authority #{ role || 'anonymous' }" do
|
|
before do
|
|
sign_in_as(create(:user, role)) if role
|
|
# Equal timestamps deliberately require version ordering, not a date comparison.
|
|
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
|
|
PostVersion.create!(post: record, version_no: index + 4, event_type: event,
|
|
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
|
|
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
|
end
|
|
end
|
|
|
|
it 'keeps every earlier discard boundary private but counts denied records' do
|
|
result = query
|
|
expected = role == :admin ? (1..7).to_a : [7]
|
|
expect(versions(result)).to eq(expected)
|
|
expect(result).to include('output_count' => 7,
|
|
'visible_output_count' => expected.length, 'excluded_count' => 7 - expected.length)
|
|
expect(response.headers['Cache-Control']).to include('no-store')
|
|
expect(response.body).not_to include('private snapshot') unless role == :admin
|
|
end
|
|
|
|
it 'applies the same version boundary to the ordinary history endpoint and old values' do
|
|
get '/posts/versions', params: { post: record.id, locale: 'ja' }
|
|
expect(response).to have_http_status(:ok)
|
|
rows = response.parsed_body.fetch('versions')
|
|
expect(rows.map { _1['version_no'] }).to eq(role == :admin ? (1..7).to_a.reverse : [7])
|
|
unless role == :admin
|
|
expect(rows.sole.dig('title', 'prev')).to be_nil
|
|
expect(response.body).not_to include('private snapshot')
|
|
end
|
|
end
|
|
|
|
it 'does not resolve private condition names even when the result set is empty' do
|
|
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
|
|
category: 'general', aliases: '', parent_tag_ids: '',
|
|
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
|
created_at: Time.utc(2024, 2))
|
|
result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest'))
|
|
expect(result['versions']).to be_empty
|
|
expected = role == :admin ? { "internal:#{ tag.id }" => 'secret tag' } : {}
|
|
expect(result['condition_labels']['tags']).to eq(expected)
|
|
get '/counts/definitions/posts/candidates', params: {
|
|
field: 'tags', query: 'secret', execution_at: point, locale: 'ja' }
|
|
expect(response).to have_http_status(:ok)
|
|
expect(response.parsed_body['rows']).to be_empty
|
|
end
|
|
end
|
|
end
|
|
it 'includes private history in the public timeline without an exclusion count' do
|
|
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
|
|
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
|
|
created_at: Time.utc(2024, 3))
|
|
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
|
|
expect(result['excluded_count']).to be_nil
|
|
expect(result['count']).to eq(4)
|
|
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
|
expect(response.body).not_to include('hidden title')
|
|
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
|
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
|
expect(masked['cells'].first['count']).to eq(4)
|
|
expect(query['versions']).to be_empty
|
|
end
|
|
|
|
it 'rejects private summary predicates instead of exposing a count oracle' do
|
|
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
|
expect(response).to have_http_status(:forbidden)
|
|
expect(json).to eq('error' => 'private_condition')
|
|
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
|