require 'rails_helper' RSpec.describe 'Counts API', type: :request do let!(:actor) { create(:user, :member) } let!(:post) { create(:post) } let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) } before do snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag', category: 'general', sections: [] } [1, 2, 3].each do |number| time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0) PostVersion.create!(post:, version_no: number, event_type: number == 1 ? 'create' : 'update', tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '', tags_json: number == 1 ? [snapshot] : [], created_by_user: number == 3 ? nil : actor, created_at: time) TagVersion.create!(tag:, version_no: number, event_type: number == 1 ? 'create' : 'update', name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name: 'counts tag', script_code: 'Jpan', auto_generated: false, aliases: [] } }, created_by_user: number == 3 ? nil : actor, created_at: time) end end ['posts', 'tags'].each do |resource| context resource do let(:path) { "/counts/#{ resource }" } let(:id) { resource == 'posts' ? post.id : tag.id } let(:filters) { { resource_id: id, user_id: actor.id, from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } } it 'uses the same AND filters for SQL totals, groups and details' do get path, params: filters.merge(axis: 'event_type') expect(response).to have_http_status(:ok) summary = response.parsed_body expect(summary['count']).to eq(2) expect(summary['groups'].sum { _1['count'] }).to eq(2) expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update']) get path, params: filters.merge(mode: 'details') expect(response.parsed_body['count']).to eq(2) expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1]) end it 'paginates details and groups independently from totals' do get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 } expect(response.parsed_body['count']).to eq(3) expect(response.parsed_body['versions'].sole['version_no']).to eq(2) get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 } expect(response.parsed_body['group_count']).to eq(2) expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1) end it 'groups null actors and supports their drill-down filter' do get path, params: { resource_id: id, axis: 'user' } expect(response.parsed_body['groups']).to include( { 'key' => nil, 'name' => nil, 'count' => 1 }) get path, params: { resource_id: id, mode: 'details', user_id: 'none' } expect(response.parsed_body['count']).to eq(1) end it 'uses an inclusive start and exclusive end' do get path, params: { resource_id: id, from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' } expect(response.parsed_body['count']).to eq(1) end it 'rejects unsupported or malformed query values' do [ { axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' }, { event_type: 'invalid' }, { columns: 'password' }, { order: 'id' }, { from: '2024-02-30T00:00Z' }, { from: '2024-01-01' }, { user_id: ['1'] }, { mode: 'invalid' }].each do |invalid| get path, params: invalid expect(response).to have_http_status(:bad_request) end end it 'retains the existing banned-user boundary' do actor.update!(banned_at: Time.current) sign_in_as(actor) get path expect(response).to have_http_status(:forbidden) end end end it 'matches removed tags from the previous snapshot, not current post_tags' do get '/counts/posts', params: { tag: tag.id, mode: 'details' } expect(response.parsed_body['count']).to eq(2) expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1]) expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed') end end