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' }, { 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq', 'conditions.0.value' => 'hidden-title-0' }, { 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string', 'drill.0.value' => "internal:#{ tags.first.id }" }, { 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string', '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' => '', 'column_order.0.value_type' => 'string', '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